Added shareable reports

This commit is contained in:
Constantin Graf
2024-08-13 16:21:59 +02:00
committed by Constantin Graf
parent 0ee0175f04
commit c03aad1abd
30 changed files with 1912 additions and 141 deletions

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Report;
use App\Models\Report;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon;
class ReportSetExpiredToPrivateCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'report:set-expired-to-private '.
' { --dry-run : Do not actually save anything to the database, just output what would happen }';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Makes public reports private if the public_until date has passed.';
/**
* Execute the console command.
*/
public function handle(): int
{
$this->comment('Makes public reports private if the public_until date has passed...');
$dryRun = (bool) $this->option('dry-run');
if ($dryRun) {
$this->comment('Running in dry-run mode. Nothing will be saved to the database.');
}
$resetReports = 0;
Report::query()
->where('public_until', '<', Carbon::now())
->orderBy('created_at', 'asc')
->chunk(500, function (Collection $reports) use ($dryRun, &$resetReports): void {
/** @var Collection<int, Report> $reports */
foreach ($reports as $report) {
$this->info('Make report "'.$report->name.'" ('.$report->getKey().') private, expired: '.$report->public_until->toIso8601ZuluString().' ('.$report->public_until->diffForHumans().')');
$resetReports++;
if (! $dryRun) {
$report->is_public = false;
$report->share_secret = null;
$report->save();
}
}
});
$this->comment('Finished setting '.$resetReports.' expired reports to private...');
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1\Public;
use App\Http\Controllers\Api\V1\Controller;
use App\Http\Resources\V1\Report\DetailedReportResource;
use App\Models\Report;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\Request;
class ReportController extends Controller
{
/**
* Get report by a share secret
*
* This endpoint is public and does not require authentication. The report must be public and not expired.
* The report is considered expired if the `public_until` field is set and the date is in the past.
* The report is considered public if the `is_public` field is set to `true`.
*
* @operationId getPublicReport
*/
public function show(Request $request): DetailedReportResource
{
$shareSecret = $request->header('X-Api-Key');
if (! is_string($shareSecret)) {
throw new ModelNotFoundException;
}
$report = Report::query()
->where('share_secret', '=', $shareSecret)
->where('is_public', '=', true)
->where(function (Builder $builder): void {
/** @var Builder<Report> $builder */
$builder->whereNull('public_until')
->orWhere('public_until', '>', now());
})
->firstOrFail();
return new DetailedReportResource($report);
}
}

View File

@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Enums\TimeEntryAggregationType;
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\Models\Organization;
use App\Models\Report;
use App\Service\Dto\ReportPropertiesDto;
use App\Service\ReportService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
class ReportController extends Controller
{
/**
* @throws AuthorizationException
*/
protected function checkPermission(Organization $organization, string $permission, ?Report $report = null): void
{
parent::checkPermission($organization, $permission);
if ($report !== null && $report->organization_id !== $organization->id) {
throw new AuthorizationException('Report does not belong to organization');
}
}
/**
* Get reports
*
* @throws AuthorizationException
*
* @operationId getReports
*/
public function index(Organization $organization): ReportCollection
{
$this->checkPermission($organization, 'reports:view');
$reports = Report::query()
->orderBy('created_at', 'desc')
->whereBelongsTo($organization, 'organization')
->paginate(config('app.pagination_per_page_default'));
return new ReportCollection($reports);
}
/**
* Get report
*
* @throws AuthorizationException
*
* @operationId getReport
*/
public function show(Organization $organization, Report $report): DetailedReportResource
{
$this->checkPermission($organization, 'reports:view', $report);
return new DetailedReportResource($report);
}
/**
* Create report
*
* @throws AuthorizationException
*
* @operationId createReport
*/
public function store(Organization $organization, ReportStoreRequest $request): DetailedReportResource
{
$this->checkPermission($organization, 'reports:create');
$report = new Report;
$report->name = $request->getName();
$report->description = $request->getDescription();
$isPublic = $request->getIsPublic();
$report->is_public = $isPublic;
$properties = new ReportPropertiesDto;
$properties->group = TimeEntryAggregationType::from($request->input('properties.group'));
$properties->subGroup = TimeEntryAggregationType::from($request->input('properties.sub_group'));
$report->properties = $properties;
if ($isPublic) {
$report->share_secret = app(ReportService::class)->generateSecret();
$report->public_until = $request->getPublicUntil();
} else {
$report->share_secret = null;
$report->public_until = null;
}
$report->organization()->associate($organization);
$report->save();
return new DetailedReportResource($report);
}
/**
* Update report
*
* @throws AuthorizationException
*
* @operationId updateReport
*/
public function update(Organization $organization, Report $report, ReportUpdateRequest $request): DetailedReportResource
{
$this->checkPermission($organization, 'reports:update', $report);
if ($request->has('name')) {
$report->name = $request->getName();
}
if ($request->has('description')) {
$report->description = $request->getDescription();
}
if ($request->has('is_public') && $request->getIsPublic() !== $report->is_public) {
$isPublic = $request->getIsPublic();
$report->is_public = $isPublic;
if ($isPublic) {
$report->share_secret = app(ReportService::class)->generateSecret();
$report->public_until = $request->getPublicUntil();
} else {
$report->share_secret = null;
$report->public_until = null;
}
}
$report->save();
return new DetailedReportResource($report);
}
/**
* Delete report
*
* @throws AuthorizationException
*
* @operationId deleteReport
*/
public function destroy(Organization $organization, Report $report): JsonResponse
{
$this->checkPermission($organization, 'reports:delete', $report);
$report->delete();
return response()->json(null, 204);
}
}

View File

@@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\Report;
use App\Enums\TimeEntryAggregationType;
use App\Models\Organization;
use Illuminate\Contracts\Validation\Rule as LegacyValidationRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Carbon;
use Illuminate\Validation\Rule;
/**
* @property Organization $organization Organization from model binding
*/
class ReportStoreRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule|LegacyValidationRule>>
*/
public function rules(): array
{
return [
'name' => [
'required',
'string',
'max:255',
],
'description' => [
'nullable',
'string',
],
'is_public' => [
'required',
'boolean',
],
// After this date the report will be automatically set to private (is_public=false) (ISO 8601 format, UTC timezone)
'public_until' => [
'nullable',
'date_format:Y-m-d\TH:i:s\Z',
'after:now',
],
'properties' => [
'required',
'array',
],
'properties.start' => [
'nullable',
'date_format:Y-m-d\TH:i:s\Z',
],
'properties.end' => [
'nullable',
'date_format:Y-m-d\TH:i:s\Z',
],
'properties.active' => [
'nullable',
'boolean',
],
'properties.member_ids' => [
'nullable',
'array',
],
'properties.member_ids.*' => [
'string',
'uuid',
],
'properties.billable' => [
'nullable',
'boolean',
],
'properties.client_ids' => [
'nullable',
'array',
],
'properties.client_ids.*' => [
'string',
'uuid',
],
'properties.project_ids' => [
'nullable',
'array',
],
'properties.project_ids.*' => [
'string',
'uuid',
],
'properties.tag_ids' => [
'nullable',
'array',
],
'properties.tag_ids.*' => [
'string',
'uuid',
],
'properties.task_ids' => [
'nullable',
'array',
],
'properties.task_ids.*' => [
'string',
'uuid',
],
'properties.group' => [
'required',
Rule::enum(TimeEntryAggregationType::class),
],
'properties.sub_group' => [
'required',
Rule::enum(TimeEntryAggregationType::class),
],
];
}
public function getName(): string
{
return (string) $this->input('name');
}
public function getDescription(): ?string
{
return $this->input('description');
}
public function getIsPublic(): bool
{
return (bool) $this->input('is_public');
}
public function getPublicUntil(): ?Carbon
{
$publicUntil = $this->input('public_until');
return $publicUntil === null ? null : Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $publicUntil);
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\Report;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Carbon;
/**
* @property Organization $organization Organization from model binding
*/
class ReportUpdateRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule>>
*/
public function rules(): array
{
return [
'name' => [
'string',
'max:255',
],
'description' => [
'nullable',
'string',
],
'is_public' => [
'boolean',
],
'public_until' => [
'nullable',
'date_format:Y-m-d\TH:i:s\Z',
'after:now',
],
];
}
public function getName(): string
{
return (string) $this->input('name');
}
public function getDescription(): ?string
{
return $this->input('description');
}
public function getIsPublic(): bool
{
return (bool) $this->input('is_public');
}
public function getPublicUntil(): ?Carbon
{
$publicUntil = $this->input('public_until');
return $publicUntil === null ? null : Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $publicUntil);
}
}

View File

@@ -34,7 +34,7 @@ class TimeEntryAggregateRequest extends FormRequest
return [
'group' => [
'nullable',
'required_with:group_2',
'required_with:sub_group',
Rule::enum(TimeEntryAggregationType::class),
],

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\V1\Report;
use App\Http\Resources\V1\BaseResource;
use App\Models\Report;
use Illuminate\Http\Request;
/**
* @property Report $resource
*/
class DetailedReportResource extends BaseResource
{
/**
* Transform the resource into an array.
*
* @return array<string, string|bool|int|null|array<string, string|bool|int|null|array<int, string>>>
*/
public function toArray(Request $request): array
{
return [
/** @var string $id ID of the report */
'id' => $this->resource->id,
/** @var string $name Name */
'name' => $this->resource->name,
/** @var string|null $email Description */
'description' => $this->resource->description,
/** @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->resource->public_until?->toIso8601ZuluString(),
/** @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' => [
'group' => $this->resource->properties->group->value,
'sub_group' => $this->resource->properties->subGroup->value,
/** @var string|null $start Start date of the report */
'start' => $this->resource->properties->start?->toIso8601ZuluString(),
/** @var string|null $end End date of the report */
'end' => $this->resource->properties->end?->toIso8601ZuluString(),
/** @var bool|null $active Whether the report is active */
'active' => $this->resource->properties->active,
'member_ids' => $this->resource->properties->memberIds?->toArray(),
'billable' => $this->resource->properties->billable,
'client_ids' => $this->resource->properties->clientIds?->toArray(),
'project_ids' => $this->resource->properties->projectIds?->toArray(),
'tag_ids' => $this->resource->properties->tagIds?->toArray(),
'task_ids' => $this->resource->properties->taskIds?->toArray(),
],
];
}
}

View File

@@ -0,0 +1,18 @@
<?php
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 implements PaginatedResourceCollection
{
/**
* The resource that this resource collects.
*
* @var string
*/
public $collects = ReportResource::class;
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\V1\Report;
use App\Http\Resources\V1\BaseResource;
use App\Models\Report;
use Illuminate\Http\Request;
/**
* @property Report $resource
*/
class ReportResource extends BaseResource
{
/**
* Transform the resource into an array.
*
* @return array<string, string|bool|int|null|array<string>>
*/
public function toArray(Request $request): array
{
return [
/** @var string $id ID of the report */
'id' => $this->resource->id,
/** @var string $name Name */
'name' => $this->resource->name,
/** @var string|null $email Description */
'description' => $this->resource->description,
/** @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->resource->public_until?->toIso8601ZuluString(),
/** @var string|null $shareable_link Get link to access the report externally, not set if the report is private */
'shareable_link' => $this->resource->getShareableLink(),
];
}
}

62
app/Models/Report.php Normal file
View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Models\Concerns\HasUuids;
use App\Service\Dto\ReportPropertiesDto;
use Database\Factories\ReportFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
/**
* @property string $id
* @property string $name
* @property string|null $description
* @property string $organization_id
* @property bool $is_public
* @property Carbon|null $public_until
* @property string|null $share_secret
* @property ReportPropertiesDto $properties
* @property-read Organization $organization
*
* @method static ReportFactory factory()
*/
class Report extends Model
{
/** @use HasFactory<ReportFactory> */
use HasFactory;
use HasUuids;
/**
* The attributes that should be cast.
*
* @var array<string, string>
*/
protected $casts = [
'is_public' => 'bool',
'public_until' => 'datetime',
'properties' => ReportPropertiesDto::class,
];
public function getShareableLink(): ?string
{
if ($this->is_public && $this->share_secret !== null) {
return route('shared-report').'#'.$this->share_secret;
}
return null;
}
/**
* @return BelongsTo<Organization, Report>
*/
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
}

View File

@@ -126,6 +126,10 @@ class JetstreamServiceProvider extends ServiceProvider
'members:update',
'members:delete',
'billing',
'reports:view',
'reports:create',
'reports:update',
'reports:delete',
])->description('Owner users can perform any action. There is only one owner per organization.');
Jetstream::role(Role::Admin->value, 'Administrator', [
@@ -170,6 +174,10 @@ class JetstreamServiceProvider extends ServiceProvider
'members:view',
'members:update',
'members:invite-placeholder',
'reports:view',
'reports:create',
'reports:update',
'reports:delete',
])->description('Administrator users can perform any action, except accessing the billing dashboard.');
Jetstream::role(Role::Manager->value, 'Manager', [
@@ -206,6 +214,10 @@ class JetstreamServiceProvider extends ServiceProvider
'organizations:view',
'invitations:view',
'members:view',
'reports:view',
'reports:create',
'reports:update',
'reports:delete',
])->description('Managers have full access to all projects, time entries, ect. but cannot manage the organization (add/remove member, edit the organization, ect.).');
Jetstream::role(Role::Employee->value, 'Employee', [

View File

@@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
namespace App\Service\Dto;
use App\Enums\TimeEntryAggregationType;
use Illuminate\Contracts\Database\Eloquent\Castable;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
class ReportPropertiesDto implements Castable
{
public ?TimeEntryAggregationType $group = null;
public ?TimeEntryAggregationType $subGroup = null;
public ?Carbon $start = null;
public ?Carbon $end = null;
public ?bool $active = null;
/**
* @var Collection<int, string>|null
*/
public ?Collection $memberIds = null;
public ?bool $billable = null;
/**
* @var Collection<int, string>|null
*/
public ?Collection $clientIds = null;
/**
* @var Collection<int, string>|null
*/
public ?Collection $projectIds = null;
/**
* @var Collection<int, string>|null
*/
public ?Collection $tagIds = null;
/**
* @var Collection<int, string>|null
*/
public ?Collection $taskIds = null;
/**
* Get the caster class to use when casting from / to this cast target.
*
* @param array<string, mixed> $arguments
* @return CastsAttributes<ReportPropertiesDto, ReportPropertiesDto>
*/
public static function castUsing(array $arguments): CastsAttributes
{
return new class implements CastsAttributes
{
private const array REQUIRED_PROPERTIES = [
'group',
'subGroup',
'start',
'end',
'active',
'memberIds',
'billable',
'clientIds',
'projectIds',
'tagIds',
'taskIds',
];
public function get(Model $model, string $key, mixed $value, array $attributes): ReportPropertiesDto
{
if (! is_string($value)) {
throw new \InvalidArgumentException('The given value is not a string');
}
$data = json_decode($value, false);
if ($data === null) {
throw new \InvalidArgumentException('The given value is not a JSON string');
}
foreach (self::REQUIRED_PROPERTIES as $property) {
if (! property_exists($data, $property)) {
throw new \InvalidArgumentException('The given JSON string does not contain the required property "'.$property.'"');
}
}
$dto = new ReportPropertiesDto;
$dto->end = $data->end !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $data->end) : null;
$dto->start = $data->start !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $data->start) : null;
$dto->active = $data->active;
$dto->memberIds = $data->memberIds !== null ? $this->idArrayToCollection($data->memberIds) : null;
$dto->billable = $data->billable;
$dto->clientIds = $data->clientIds !== null ? $this->idArrayToCollection($data->clientIds) : null;
$dto->projectIds = $data->projectIds !== null ? $this->idArrayToCollection($data->projectIds) : null;
$dto->tagIds = $data->tagIds !== null ? $this->idArrayToCollection($data->tagIds) : null;
$dto->taskIds = $data->taskIds ? $this->idArrayToCollection($data->taskIds) : null;
$dto->group = $data->group !== null ? TimeEntryAggregationType::from($data->group) : null;
$dto->subGroup = $data->subGroup !== null ? TimeEntryAggregationType::from($data->subGroup) : null;
return $dto;
}
/**
* @param array<mixed> $ids
* @return Collection<int, string>
*/
private function idArrayToCollection(array $ids): Collection
{
$collection = new Collection;
foreach ($ids as $id) {
if (! is_string($id)) {
throw new \InvalidArgumentException('The given ID is not a string');
}
if (Str::isUuid($id)) {
throw new \InvalidArgumentException('The given ID is not a valid UUID');
}
$collection->push($id);
}
return $collection;
}
/**
* @param ReportPropertiesDto $value
*/
public function set(Model $model, string $key, mixed $value, array $attributes): string
{
if (! ($value instanceof ReportPropertiesDto)) {
throw new \InvalidArgumentException('The given value is not an instance of ReportPropertiesDto');
}
$data = (object) [
'end' => $value->end?->toIso8601ZuluString(),
'start' => $value->start?->toIso8601ZuluString(),
'active' => $value->active,
'memberIds' => $value->memberIds?->toArray(),
'billable' => $value->billable,
'clientIds' => $value->clientIds?->toArray(),
'projectIds' => $value->projectIds?->toArray(),
'tagIds' => $value->tagIds?->toArray(),
'taskIds' => $value->taskIds?->toArray(),
'group' => $value->group?->value,
'subGroup' => $value->subGroup?->value,
];
$jsonString = json_encode($data);
if ($jsonString === false) {
throw new \InvalidArgumentException('Could not encode the given data to a JSON string');
}
return $jsonString;
}
};
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Service;
use Illuminate\Support\Str;
class ReportService
{
public function generateSecret(): string
{
return Str::random(40);
}
}

View File

@@ -29,11 +29,9 @@ class ClientFactory extends Factory
public function forOrganization(Organization $organization): self
{
return $this->state(function (array $attributes) use ($organization) {
return [
return $this->state(fn (array $attributes) => [
'organization_id' => $organization->getKey(),
];
});
]);
}
public function randomCreatedAt(): self

View File

@@ -41,20 +41,16 @@ class MemberFactory extends Factory
public function forOrganization(Organization $organization): static
{
return $this->state(function (array $attributes) use ($organization): array {
return [
return $this->state(fn (array $attributes): array => [
'organization_id' => $organization->getKey(),
];
});
]);
}
public function forUser(User $user): static
{
return $this->state(function (array $attributes) use ($user): array {
return [
return $this->state(fn (array $attributes): array => [
'user_id' => $user->getKey(),
];
});
]);
}
/**

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Enums\TimeEntryAggregationType;
use App\Models\Organization;
use App\Models\Report;
use App\Service\Dto\ReportPropertiesDto;
use App\Service\ReportService;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Report>
*/
class ReportFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
$reportDto = new ReportPropertiesDto;
$reportDto->group = TimeEntryAggregationType::Project;
$reportDto->subGroup = TimeEntryAggregationType::Task;
return [
'name' => $this->faker->company(),
'description' => $this->faker->paragraph(),
'is_public' => $this->faker->boolean(),
'properties' => $reportDto,
'organization_id' => Organization::factory(),
];
}
public function randomCreatedAt(): self
{
return $this->state(fn (array $attributes): array => [
'created_at' => $this->faker->dateTimeBetween('-1 year', 'now'),
]);
}
public function public(): self
{
return $this->state(fn (array $attributes): array => [
'is_public' => true,
'share_secret' => app(ReportService::class)->generateSecret(),
]);
}
public function private(): self
{
return $this->state(fn (array $attributes): array => [
'is_public' => false,
'share_secret' => null,
]);
}
public function forOrganization(Organization $organization): self
{
return $this->state(fn (array $attributes): array => [
'organization_id' => $organization->getKey(),
]);
}
}

View File

@@ -32,28 +32,22 @@ class TaskFactory extends Factory
public function forProject(Project $project): self
{
return $this->state(function (array $attributes) use ($project) {
return [
return $this->state(fn (array $attributes) => [
'project_id' => $project->getKey(),
];
});
]);
}
public function isDone(): self
{
return $this->state(function (array $attributes) {
return [
return $this->state(fn (array $attributes) => [
'done_at' => $this->faker->dateTime('now', 'UTC'),
];
});
]);
}
public function forOrganization(Organization $organization): self
{
return $this->state(function (array $attributes) use ($organization) {
return [
return $this->state(fn (array $attributes) => [
'organization_id' => $organization->getKey(),
];
});
]);
}
}

View File

@@ -173,22 +173,18 @@ class TimeEntryFactory extends Factory
public function forProject(?Project $project): self
{
return $this->state(function (array $attributes) use ($project) {
return [
return $this->state(fn (array $attributes) => [
'project_id' => $project?->getKey(),
'client_id' => $project?->client_id,
];
});
]);
}
public function forTask(?Task $task): self
{
return $this->state(function (array $attributes) use ($task) {
return [
return $this->state(fn (array $attributes) => [
'task_id' => $task?->getKey(),
'project_id' => $task?->project?->getKey(),
'client_id' => $task?->project?->client?->getKey(),
];
});
]);
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('reports', function (Blueprint $table): void {
$table->uuid('id')->primary();
$table->string('name');
$table->text('description')->nullable();
$table->boolean('is_public')->default(false)->index();
$table->string('share_secret', 40)->nullable()->index()->unique();
$table->jsonb('properties');
$table->dateTime('public_until')->nullable();
$table->uuid('organization_id');
$table->foreign('organization_id')
->references('id')
->on('organizations')
->restrictOnDelete()
->cascadeOnUpdate();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('reports');
}
};

View File

@@ -10,6 +10,8 @@ use App\Http\Controllers\Api\V1\MemberController;
use App\Http\Controllers\Api\V1\OrganizationController;
use App\Http\Controllers\Api\V1\ProjectController;
use App\Http\Controllers\Api\V1\ProjectMemberController;
use App\Http\Controllers\Api\V1\Public\ReportController as PublicReportController;
use App\Http\Controllers\Api\V1\ReportController;
use App\Http\Controllers\Api\V1\TagController;
use App\Http\Controllers\Api\V1\TaskController;
use App\Http\Controllers\Api\V1\TimeEntryController;
@@ -30,23 +32,24 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
*/
Route::middleware([
Route::prefix('v1')->name('v1.')->group(static function (): void {
Route::middleware([
'auth:api',
'verified',
])->prefix('v1')->name('v1.')->group(static function (): void {
])->group(static function (): void {
// Organization routes
Route::name('organizations.')->group(static function (): void {
Route::get('/organizations/{organization}', [OrganizationController::class, 'show'])->name('show');
Route::put('/organizations/{organization}', [OrganizationController::class, 'update'])->name('update')->middleware('check-organization-blocked');
Route::put('/organizations/{organization}', [OrganizationController::class, 'update'])->name('update');
});
// Member routes
Route::name('members.')->group(static function (): void {
Route::get('/organizations/{organization}/members', [MemberController::class, 'index'])->name('index');
Route::put('/organizations/{organization}/members/{member}', [MemberController::class, 'update'])->name('update');
Route::delete('/organizations/{organization}/members/{member}', [MemberController::class, 'destroy'])->name('destroy');
Route::post('/organizations/{organization}/members/{member}/invite-placeholder', [MemberController::class, 'invitePlaceholder'])->name('invite-placeholder');
Route::post('/organizations/{organization}/members/{member}/make-placeholder', [MemberController::class, 'makePlaceholder'])->name('make-placeholder');
Route::name('members.')->prefix('/organizations/{organization}')->group(static function (): void {
Route::get('/members', [MemberController::class, 'index'])->name('index');
Route::put('/members/{member}', [MemberController::class, 'update'])->name('update');
Route::delete('/members/{member}', [MemberController::class, 'destroy'])->name('destroy');
Route::post('/members/{member}/invite-placeholder', [MemberController::class, 'invitePlaceholder'])->name('invite-placeholder');
Route::post('/members/{member}/make-placeholder', [MemberController::class, 'makePlaceholder'])->name('make-placeholder');
});
// User routes
@@ -60,81 +63,96 @@ Route::middleware([
});
// Invitation routes
Route::name('invitations.')->group(static function (): void {
Route::get('/organizations/{organization}/invitations', [InvitationController::class, 'index'])->name('index');
Route::post('/organizations/{organization}/invitations', [InvitationController::class, 'store'])->name('store')->middleware('check-organization-blocked');
Route::post('/organizations/{organization}/invitations/{invitation}/resend', [InvitationController::class, 'resend'])->name('resend')->middleware('check-organization-blocked');
Route::delete('/organizations/{organization}/invitations/{invitation}', [InvitationController::class, 'destroy'])->name('destroy')->middleware('check-organization-blocked');
Route::name('invitations.')->prefix('/organizations/{organization}')->group(static function (): void {
Route::get('/invitations', [InvitationController::class, 'index'])->name('index');
Route::post('/invitations', [InvitationController::class, 'store'])->name('store')->middleware('check-organization-blocked');
Route::post('/invitations/{invitation}/resend', [InvitationController::class, 'resend'])->name('resend')->middleware('check-organization-blocked');
Route::delete('/invitations/{invitation}', [InvitationController::class, 'destroy'])->name('destroy')->middleware('check-organization-blocked');
});
// Project routes
Route::name('projects.')->group(static function (): void {
Route::get('/organizations/{organization}/projects', [ProjectController::class, 'index'])->name('index');
Route::get('/organizations/{organization}/projects/{project}', [ProjectController::class, 'show'])->name('show');
Route::post('/organizations/{organization}/projects', [ProjectController::class, 'store'])->name('store')->middleware('check-organization-blocked');
Route::put('/organizations/{organization}/projects/{project}', [ProjectController::class, 'update'])->name('update')->middleware('check-organization-blocked');
Route::delete('/organizations/{organization}/projects/{project}', [ProjectController::class, 'destroy'])->name('destroy');
Route::name('projects.')->prefix('/organizations/{organization}')->group(static function (): void {
Route::get('/projects', [ProjectController::class, 'index'])->name('index');
Route::get('/projects/{project}', [ProjectController::class, 'show'])->name('show');
Route::post('/projects', [ProjectController::class, 'store'])->name('store')->middleware('check-organization-blocked');
Route::put('/projects/{project}', [ProjectController::class, 'update'])->name('update')->middleware('check-organization-blocked');
Route::delete('/projects/{project}', [ProjectController::class, 'destroy'])->name('destroy');
});
// Project member routes
Route::name('project-members.')->group(static function (): void {
Route::get('/organizations/{organization}/projects/{project}/project-members', [ProjectMemberController::class, 'index'])->name('index');
Route::post('/organizations/{organization}/projects/{project}/project-members', [ProjectMemberController::class, 'store'])->name('store')->middleware('check-organization-blocked');
Route::put('/organizations/{organization}/project-members/{projectMember}', [ProjectMemberController::class, 'update'])->name('update')->middleware('check-organization-blocked');
Route::delete('/organizations/{organization}/project-members/{projectMember}', [ProjectMemberController::class, 'destroy'])->name('destroy');
Route::name('project-members.')->prefix('/organizations/{organization}')->group(static function (): void {
Route::get('/projects/{project}/project-members', [ProjectMemberController::class, 'index'])->name('index');
Route::post('/projects/{project}/project-members', [ProjectMemberController::class, 'store'])->name('store')->middleware('check-organization-blocked');
Route::put('/project-members/{projectMember}', [ProjectMemberController::class, 'update'])->name('update')->middleware('check-organization-blocked');
Route::delete('/project-members/{projectMember}', [ProjectMemberController::class, 'destroy'])->name('destroy');
});
// Time entry routes
Route::name('time-entries.')->group(static function (): void {
Route::get('/organizations/{organization}/time-entries', [TimeEntryController::class, 'index'])->name('index');
Route::get('/organizations/{organization}/time-entries/export', [TimeEntryController::class, 'indexExport'])->name('index-export');
Route::get('/organizations/{organization}/time-entries/aggregate', [TimeEntryController::class, 'aggregate'])->name('aggregate');
Route::get('/organizations/{organization}/time-entries/aggregate/export', [TimeEntryController::class, 'aggregateExport'])->name('aggregate-export');
Route::post('/organizations/{organization}/time-entries', [TimeEntryController::class, 'store'])->name('store')->middleware('check-organization-blocked');
Route::put('/organizations/{organization}/time-entries/{timeEntry}', [TimeEntryController::class, 'update'])->name('update')->middleware('check-organization-blocked');
Route::patch('/organizations/{organization}/time-entries', [TimeEntryController::class, 'updateMultiple'])->name('update-multiple')->middleware('check-organization-blocked');
Route::delete('/organizations/{organization}/time-entries/{timeEntry}', [TimeEntryController::class, 'destroy'])->name('destroy');
Route::delete('/organizations/{organization}/time-entries', [TimeEntryController::class, 'destroyMultiple'])->name('destroy-multiple');
Route::name('time-entries.')->prefix('/organizations/{organization}')->group(static function (): void {
Route::get('/time-entries', [TimeEntryController::class, 'index'])->name('index');
Route::get('/time-entries/export', [TimeEntryController::class, 'indexExport'])->name('index-export');
Route::get('/time-entries/aggregate', [TimeEntryController::class, 'aggregate'])->name('aggregate');
Route::get('/time-entries/aggregate/export', [TimeEntryController::class, 'aggregateExport'])->name('aggregate-export');
Route::post('/time-entries', [TimeEntryController::class, 'store'])->name('store')->middleware('check-organization-blocked');
Route::put('/time-entries/{timeEntry}', [TimeEntryController::class, 'update'])->name('update')->middleware('check-organization-blocked');
Route::patch('/time-entries', [TimeEntryController::class, 'updateMultiple'])->name('update-multiple')->middleware('check-organization-blocked');
Route::delete('/time-entries/{timeEntry}', [TimeEntryController::class, 'destroy'])->name('destroy');
Route::delete('/time-entries', [TimeEntryController::class, 'destroyMultiple'])->name('destroy-multiple');
});
Route::name('users.time-entries.')->group(static function (): void {
Route::get('/users/me/time-entries/active', [UserTimeEntryController::class, 'myActive'])->name('my-active');
});
// Report routes
Route::name('reports.')->prefix('/organizations/{organization}')->group(static function (): void {
Route::get('/reports', [ReportController::class, 'index'])->name('index');
Route::get('/reports/{report}', [ReportController::class, 'show'])->name('show');
Route::post('/reports', [ReportController::class, 'store'])->name('store');
Route::put('/reports/{report}', [ReportController::class, 'update'])->name('update');
Route::delete('/reports/{report}', [ReportController::class, 'destroy'])->name('destroy');
});
// Tag routes
Route::name('tags.')->group(static function (): void {
Route::get('/organizations/{organization}/tags', [TagController::class, 'index'])->name('index');
Route::post('/organizations/{organization}/tags', [TagController::class, 'store'])->name('store')->middleware('check-organization-blocked');
Route::put('/organizations/{organization}/tags/{tag}', [TagController::class, 'update'])->name('update')->middleware('check-organization-blocked');
Route::delete('/organizations/{organization}/tags/{tag}', [TagController::class, 'destroy'])->name('destroy');
Route::name('tags.')->prefix('/organizations/{organization}')->group(static function (): void {
Route::get('/tags', [TagController::class, 'index'])->name('index');
Route::post('/tags', [TagController::class, 'store'])->name('store')->middleware('check-organization-blocked');
Route::put('/tags/{tag}', [TagController::class, 'update'])->name('update')->middleware('check-organization-blocked');
Route::delete('/tags/{tag}', [TagController::class, 'destroy'])->name('destroy');
});
// Client routes
Route::name('clients.')->group(static function (): void {
Route::get('/organizations/{organization}/clients', [ClientController::class, 'index'])->name('index');
Route::post('/organizations/{organization}/clients', [ClientController::class, 'store'])->name('store')->middleware('check-organization-blocked');
Route::put('/organizations/{organization}/clients/{client}', [ClientController::class, 'update'])->name('update')->middleware('check-organization-blocked');
Route::delete('/organizations/{organization}/clients/{client}', [ClientController::class, 'destroy'])->name('destroy');
Route::name('clients.')->prefix('/organizations/{organization}')->group(static function (): void {
Route::get('/clients', [ClientController::class, 'index'])->name('index');
Route::post('/clients', [ClientController::class, 'store'])->name('store')->middleware('check-organization-blocked');
Route::put('/clients/{client}', [ClientController::class, 'update'])->name('update')->middleware('check-organization-blocked');
Route::delete('/clients/{client}', [ClientController::class, 'destroy'])->name('destroy');
});
// Task routes
Route::name('tasks.')->group(static function (): void {
Route::get('/organizations/{organization}/tasks', [TaskController::class, 'index'])->name('index');
Route::post('/organizations/{organization}/tasks', [TaskController::class, 'store'])->name('store')->middleware('check-organization-blocked');
Route::put('/organizations/{organization}/tasks/{task}', [TaskController::class, 'update'])->name('update')->middleware('check-organization-blocked');
Route::delete('/organizations/{organization}/tasks/{task}', [TaskController::class, 'destroy'])->name('destroy');
Route::name('tasks.')->prefix('/organizations/{organization}')->group(static function (): void {
Route::get('/tasks', [TaskController::class, 'index'])->name('index');
Route::post('/tasks', [TaskController::class, 'store'])->name('store')->middleware('check-organization-blocked');
Route::put('/tasks/{task}', [TaskController::class, 'update'])->name('update')->middleware('check-organization-blocked');
Route::delete('/tasks/{task}', [TaskController::class, 'destroy'])->name('destroy');
});
// Import routes
Route::name('import.')->group(static function (): void {
Route::get('/organizations/{organization}/importers', [ImportController::class, 'index'])->name('index');
Route::post('/organizations/{organization}/import', [ImportController::class, 'import'])->name('import')->middleware('check-organization-blocked');
Route::name('import.')->prefix('/organizations/{organization}')->group(static function (): void {
Route::get('/importers', [ImportController::class, 'index'])->name('index');
Route::post('/import', [ImportController::class, 'import'])->name('import')->middleware('check-organization-blocked');
});
// Export routes
Route::name('export.')->prefix('/organizations/{organization}')->group(static function (): void {
Route::post('/export', [ExportController::class, 'export'])->name('export');
});
});
// Public routes
Route::name('public.')->prefix('/public')->group(static function (): void {
Route::get('/reports', [PublicReportController::class, 'show'])->name('reports.show');
});
});
/**

View File

@@ -25,6 +25,10 @@ use Laravel\Jetstream\Jetstream;
Route::get('/', [HomeController::class, 'index']);
Route::get('/shared-report', function () {
return Inertia::render('SharedReport');
})->name('shared-report');
Route::middleware([
'auth:web',
config('jetstream.auth_session'),

View File

@@ -32,7 +32,7 @@ class DeleteOrganizationTest extends TestCase
);
// Act
$response = $this->withoutExceptionHandling()->delete('/teams/'.$organization->getKey());
$response = $this->delete('/teams/'.$organization->getKey());
// Assert
$this->assertNull($organization->fresh());

View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Console\Commands\Report;
use App\Console\Commands\Report\ReportSetExpiredToPrivateCommand;
use App\Models\Report;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\UsesClass;
use Tests\TestCaseWithDatabase;
#[CoversClass(ReportSetExpiredToPrivateCommand::class)]
#[UsesClass(ReportSetExpiredToPrivateCommand::class)]
class ReportSetExpiredToPrivateCommandTest extends TestCaseWithDatabase
{
public function test_command_sets_expired_reports_to_private(): void
{
// Arrange
$reportPrivateExpired = Report::factory()->private()->create([
'public_until' => now()->subDay(),
]);
$reportPublicExpired = Report::factory()->public()->create([
'public_until' => now()->subDay(),
]);
$reportPrivateNoExpiration = Report::factory()->private()->create([
'public_until' => null,
]);
$reportPublicNoExpiration = Report::factory()->public()->create([
'public_until' => null,
]);
$reportPrivateNotExpired = Report::factory()->private()->create([
'public_until' => now()->addDay(),
]);
$reportPublicNotExpired = Report::factory()->public()->create([
'public_until' => now()->addDay(),
]);
// Act
$exitCode = $this->withoutMockingConsoleOutput()->artisan('report:set-expired-to-private');
// Assert
$this->assertSame(Command::SUCCESS, $exitCode);
$output = Artisan::output();
$this->assertStringContainsString('Makes public reports private if the public_until date has passed...', $output);
$this->assertStringContainsString('Make report "'.$reportPrivateExpired->name.'" ('.$reportPrivateExpired->getKey().') private, expired: '.$reportPrivateExpired->public_until->toIso8601ZuluString().' ('.$reportPrivateExpired->public_until->diffForHumans().')', $output);
$this->assertStringContainsString('Make report "'.$reportPublicExpired->name.'" ('.$reportPublicExpired->getKey().') private, expired: '.$reportPublicExpired->public_until->toIso8601ZuluString().' ('.$reportPublicExpired->public_until->diffForHumans().')', $output);
$this->assertStringContainsString('Finished setting 2 expired reports to private...', $output);
$reportPrivateExpired->refresh();
$reportPublicExpired->refresh();
$reportPrivateNoExpiration->refresh();
$reportPublicNoExpiration->refresh();
$reportPrivateNotExpired->refresh();
$reportPublicNotExpired->refresh();
$this->assertFalse($reportPrivateExpired->is_public);
$this->assertNull($reportPrivateExpired->share_secret);
$this->assertFalse($reportPublicExpired->is_public);
$this->assertNull($reportPublicExpired->share_secret);
$this->assertFalse($reportPrivateNoExpiration->is_public);
$this->assertNull($reportPrivateNoExpiration->share_secret);
$this->assertTrue($reportPublicNoExpiration->is_public);
$this->assertNotNull($reportPublicNoExpiration->share_secret);
$this->assertFalse($reportPrivateNotExpired->is_public);
$this->assertNull($reportPrivateNotExpired->share_secret);
$this->assertTrue($reportPublicNotExpired->is_public);
$this->assertNotNull($reportPublicNotExpired->share_secret);
}
public function test_command_sets_expired_reports_to_private_in_dry_run_mode(): void
{
// Arrange
$reportPrivateExpired = Report::factory()->private()->create([
'public_until' => now()->subDay(),
]);
$reportPublicExpired = Report::factory()->public()->create([
'public_until' => now()->subDay(),
]);
$reportPrivateNoExpiration = Report::factory()->private()->create([
'public_until' => null,
]);
$reportPublicNoExpiration = Report::factory()->public()->create([
'public_until' => null,
]);
$reportPrivateNotExpired = Report::factory()->private()->create([
'public_until' => now()->addDay(),
]);
$reportPublicNotExpired = Report::factory()->public()->create([
'public_until' => now()->addDay(),
]);
// Act
$exitCode = $this->withoutMockingConsoleOutput()->artisan('report:set-expired-to-private', ['--dry-run' => true]);
// Assert
$this->assertSame(Command::SUCCESS, $exitCode);
$output = Artisan::output();
$this->assertStringContainsString('Makes public reports private if the public_until date has passed...', $output);
$this->assertStringContainsString('Running in dry-run mode. Nothing will be saved to the database.', $output);
$this->assertStringContainsString('Make report "'.$reportPrivateExpired->name.'" ('.$reportPrivateExpired->getKey().') private, expired: '.$reportPrivateExpired->public_until->toIso8601ZuluString().' ('.$reportPrivateExpired->public_until->diffForHumans().')', $output);
$this->assertStringContainsString('Make report "'.$reportPublicExpired->name.'" ('.$reportPublicExpired->getKey().') private, expired: '.$reportPublicExpired->public_until->toIso8601ZuluString().' ('.$reportPublicExpired->public_until->diffForHumans().')', $output);
$this->assertStringContainsString('Finished setting 2 expired reports to private...', $output);
$reportPrivateExpired->refresh();
$reportPublicExpired->refresh();
$reportPrivateNoExpiration->refresh();
$reportPublicNoExpiration->refresh();
$reportPrivateNotExpired->refresh();
$reportPublicNotExpired->refresh();
$this->assertFalse($reportPrivateExpired->is_public);
$this->assertNull($reportPrivateExpired->share_secret);
$this->assertTrue($reportPublicExpired->is_public);
$this->assertNotNull($reportPublicExpired->share_secret);
$this->assertFalse($reportPrivateNoExpiration->is_public);
$this->assertNull($reportPrivateNoExpiration->share_secret);
$this->assertTrue($reportPublicNoExpiration->is_public);
$this->assertNotNull($reportPublicNoExpiration->share_secret);
$this->assertFalse($reportPrivateNotExpired->is_public);
$this->assertNull($reportPrivateNotExpired->share_secret);
$this->assertTrue($reportPublicNotExpired->is_public);
$this->assertNotNull($reportPublicNotExpired->share_secret);
}
}

View File

@@ -59,4 +59,17 @@ class SelfHostGenerateKeysCommandTest extends TestCase
$this->assertStringContainsString("PASSPORT_PRIVATE_KEY: |\n -----BEGIN PRIVATE KEY-----", $output);
$this->assertStringContainsString("PASSPORT_PUBLIC_KEY: |\n -----BEGIN PUBLIC KEY-----", $output);
}
public function test_generates_app_fail_if_attribute_format_is_invalid(): void
{
// Arrange
// Act
$exitCode = $this->withoutMockingConsoleOutput()->artisan('self-host:generate-keys --format=invalid');
// Assert
$this->assertSame(Command::FAILURE, $exitCode);
$output = Artisan::output();
$this->assertSame("Invalid format\n", $output);
}
}

View File

@@ -200,7 +200,7 @@ class OrganizationEndpointTest extends ApiEndpointTestAbstract
Passport::actingAs($data->user);
// Act
$response = $this->withoutExceptionHandling()->putJson(route('api.v1.organizations.update', [$data->organization->getKey()]), [
$response = $this->putJson(route('api.v1.organizations.update', [$data->organization->getKey()]), [
'name' => $organizationFake->name,
'billable_rate' => $organizationFake->billable_rate,
]);

View File

@@ -23,7 +23,7 @@ class ProjectMemberEndpointTest extends ApiEndpointTestAbstract
// Arrange
$data = $this->createUserWithPermission();
$project = Project::factory()->forOrganization($data->organization)->create();
$projectMembers = ProjectMember::factory()->forProject($project)->createMany(4);
ProjectMember::factory()->forProject($project)->createMany(4);
Passport::actingAs($data->user);
// Act
@@ -46,7 +46,7 @@ class ProjectMemberEndpointTest extends ApiEndpointTestAbstract
'project-members:view',
]);
$project = Project::factory()->forOrganization($otherData->organization)->create();
$projectMembers = ProjectMember::factory()->forProject($project)->createMany(4);
ProjectMember::factory()->forProject($project)->createMany(4);
Passport::actingAs($data->user);
// Act
@@ -66,7 +66,7 @@ class ProjectMemberEndpointTest extends ApiEndpointTestAbstract
'project-members:view',
]);
$project = Project::factory()->forOrganization($data->organization)->create();
$projectMembers = ProjectMember::factory()->forProject($project)->createMany(4);
ProjectMember::factory()->forProject($project)->createMany(4);
Passport::actingAs($data->user);
// Act

View File

@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Endpoint\Api\V1\Public;
use App\Models\Report;
use Tests\Unit\Endpoint\Api\V1\ApiEndpointTestAbstract;
class PublicReportEndpointTest extends ApiEndpointTestAbstract
{
public function test_show_fails_with_not_found_if_secret_is_incorrect(): void
{
// Arrange
Report::factory()->public()->create();
// Act
$response = $this->getJson(route('api.v1.public.reports.show'), [
'X-Api-Key' => 'incorrect-secret',
]);
// Assert
$response->assertNotFound();
}
public function test_show_fails_with_not_found_if_no_secret_is_provided(): void
{
// Arrange
Report::factory()->public()->create();
// Act
$response = $this->getJson(route('api.v1.public.reports.show'));
// Assert
$response->assertNotFound();
}
public function test_show_fails_with_not_found_if_report_is_not_public(): void
{
// Arrange
$report = Report::factory()->private()->create();
// Act
$response = $this->getJson(route('api.v1.public.reports.show'), [
'X-Api-Key' => $report->share_secret,
]);
// Assert
$response->assertNotFound();
}
public function test_show_fails_with_not_found_if_report_is_expired(): void
{
// Arrange
$report = Report::factory()->public()->create([
'public_until' => now()->subDay(),
]);
// Act
$response = $this->getJson(route('api.v1.public.reports.show'), [
'X-Api-Key' => $report->share_secret,
]);
// Assert
$response->assertNotFound();
}
public function test_show_returns_detailed_information_about_the_report(): void
{
// Arrange
$report = Report::factory()->public()->create([
'public_until' => null,
]);
// Act
$response = $this->getJson(route('api.v1.public.reports.show'), [
'X-Api-Key' => $report->share_secret,
]);
// Assert
$response->assertOk();
$response->assertJsonFragment([
'id' => $report->id,
'name' => $report->name,
'description' => $report->description,
'is_public' => $report->is_public,
'public_until' => $report->public_until?->toIso8601ZuluString(),
]);
}
public function test_show_returns_detailed_information_about_the_report_with_not_expired_expiration_date(): void
{
// Arrange
$report = Report::factory()->public()->create([
'public_until' => now()->addDay(),
]);
// Act
$response = $this->getJson(route('api.v1.public.reports.show'), [
'X-Api-Key' => $report->share_secret,
]);
// Assert
$response->assertOk();
$response->assertJsonFragment([
'id' => $report->id,
'name' => $report->name,
'description' => $report->description,
'is_public' => $report->is_public,
'public_until' => $report->public_until?->toIso8601ZuluString(),
]);
}
}

View File

@@ -0,0 +1,490 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Endpoint\Api\V1;
use App\Enums\TimeEntryAggregationType;
use App\Http\Controllers\Api\V1\ReportController;
use App\Models\Report;
use Illuminate\Support\Carbon;
use Illuminate\Testing\Fluent\AssertableJson;
use Laravel\Passport\Passport;
use PHPUnit\Framework\Attributes\UsesClass;
#[UsesClass(ReportController::class)]
class ReportEndpointTest extends ApiEndpointTestAbstract
{
public function test_index_endpoint_fails_if_user_does_not_have_permission_to_view_reports(): void
{
// Arrange
$data = $this->createUserWithPermission();
Report::factory()->forOrganization($data->organization)->createMany(4);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.reports.index', ['organization' => $data->organization->getKey()]));
// Assert
$response->assertForbidden();
}
public function test_index_endpoint_returns_list_of_all_reports_of_organization_ordered_by_created_at_desc_per_default(): void
{
// Arrange
$data = $this->createUserWithPermission([
'reports:view',
]);
Report::factory()->forOrganization($data->organization)->randomCreatedAt()->createMany(4);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.reports.index', [$data->organization->getKey()]));
// Assert
$response->assertStatus(200);
$response->assertJsonCount(4, 'data');
$reports = Report::query()->orderBy('created_at', 'desc')->get();
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->has('links')
->has('meta')
->count('data', 4)
->where('data.0.id', $reports->get(0)->getKey())
->where('data.1.id', $reports->get(1)->getKey())
->where('data.2.id', $reports->get(2)->getKey())
->where('data.3.id', $reports->get(3)->getKey())
);
}
public function test_store_endpoint_fails_if_user_has_no_permission_to_create_report(): void
{
// Arrange
$data = $this->createUserWithPermission();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.reports.store', [$data->organization->getKey()]), [
'name' => 'Test Report',
'is_public' => false,
'properties' => [
'group' => TimeEntryAggregationType::Project->value,
'sub_group' => TimeEntryAggregationType::Task->value,
],
]);
// Assert
$response->assertForbidden();
}
public function test_store_endpoint_creates_new_report_with_minimal_properties(): void
{
// Arrange
$data = $this->createUserWithPermission([
'reports:create',
]);
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.reports.store', [$data->organization->getKey()]), [
'name' => 'Test Report',
'is_public' => false,
'properties' => [
'group' => TimeEntryAggregationType::Project->value,
'sub_group' => TimeEntryAggregationType::Task->value,
],
]);
// Assert
$response->assertStatus(201);
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->where('data.name', 'Test Report')
->where('data.description', null)
->where('data.is_public', false)
->where('data.shareable_link', null)
->where('data.properties.group', TimeEntryAggregationType::Project->value)
->where('data.properties.sub_group', TimeEntryAggregationType::Task->value)
);
}
public function test_store_endpoint_creates_new_report_with_all_properties(): void
{
// Arrange
$data = $this->createUserWithPermission([
'reports:create',
]);
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.reports.store', [$data->organization->getKey()]), [
'name' => 'Test Report',
'description' => 'Test description',
'is_public' => true,
'public_until' => Carbon::now()->addDays(30)->toIso8601ZuluString(),
'properties' => [
'start' => Carbon::now()->subDays(30)->toIso8601ZuluString(),
'end' => Carbon::now()->toIso8601ZuluString(),
'active' => true,
'member_ids' => [],
'billable' => true,
'client_ids' => [],
'project_ids' => [],
'tag_ids' => [],
'task_ids' => [],
'group' => TimeEntryAggregationType::Project->value,
'sub_group' => TimeEntryAggregationType::Task->value,
],
]);
// Assert
$response->assertStatus(201);
/** @var Report $report */
$report = Report::query()->findOrFail($response->json('data.id'));
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->where('data.name', 'Test Report')
->where('data.description', 'Test description')
->where('data.is_public', true)
->where('data.shareable_link', $report->getShareableLink())
->where('data.properties.group', TimeEntryAggregationType::Project->value)
->where('data.properties.sub_group', TimeEntryAggregationType::Task->value)
);
}
public function test_update_endpoint_fails_if_user_has_no_permission_to_update_report(): void
{
// Arrange
$data = $this->createUserWithPermission();
$report = Report::factory()->forOrganization($data->organization)->create();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.reports.update', [$data->organization->getKey(), $report->getKey()]), [
'name' => 'Updated Report',
]);
// Assert
$response->assertForbidden();
}
public function test_update_endpoint_fails_if_report_does_not_exist(): void
{
// Arrange
$data = $this->createUserWithPermission([
'reports:update',
]);
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.reports.update', [$data->organization->getKey(), 1]), [
'name' => 'Updated Report',
]);
// Assert
$response->assertNotFound();
}
public function test_update_endpoint_fails_if_report_does_not_belong_to_organization(): void
{
// Arrange
$data = $this->createUserWithPermission([
'reports:update',
]);
$report = Report::factory()->create();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.reports.update', [$data->organization->getKey(), $report->getKey()]), [
'name' => 'Updated Report',
]);
// Assert
$response->assertForbidden();
}
public function test_update_endpoint_can_update_only_the_name_of_the_report(): void
{
// Arrange
$data = $this->createUserWithPermission([
'reports:update',
]);
$report = Report::factory()->forOrganization($data->organization)->create();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.reports.update', [$data->organization->getKey(), $report->getKey()]), [
'name' => 'Updated Report',
]);
// Assert
$report->refresh();
$this->assertSame('Updated Report', $report->name);
$response->assertStatus(200);
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->where('data.name', 'Updated Report')
);
}
public function test_update_endpoint_can_update_only_the_description_of_the_report(): void
{
// Arrange
$data = $this->createUserWithPermission([
'reports:update',
]);
$report = Report::factory()->forOrganization($data->organization)->create();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.reports.update', [$data->organization->getKey(), $report->getKey()]), [
'description' => 'Updated description',
]);
// Assert
$report->refresh();
$this->assertSame('Updated description', $report->description);
$response->assertStatus(200);
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->where('data.description', 'Updated description')
);
}
public function test_update_endpoint_can_set_a_report_to_public_which_generates_a_new_secret(): void
{
// Arrange
$data = $this->createUserWithPermission([
'reports:update',
]);
$report = Report::factory()->private()->forOrganization($data->organization)->create();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.reports.update', [$data->organization->getKey(), $report->getKey()]), [
'is_public' => true,
]);
// Assert
$report->refresh();
$this->assertTrue($report->is_public);
$this->assertNotNull($report->share_secret);
$response->assertStatus(200);
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->where('data.is_public', true)
->where('data.shareable_link', $report->getShareableLink())
);
}
public function test_update_endpoint_can_set_a_report_to_private_which_resets_the_secret(): void
{
// Arrange
$data = $this->createUserWithPermission([
'reports:update',
]);
$report = Report::factory()->public()->forOrganization($data->organization)->create();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.reports.update', [$data->organization->getKey(), $report->getKey()]), [
'is_public' => false,
]);
// Assert
$report->refresh();
$this->assertFalse($report->is_public);
$this->assertNull($report->share_secret);
$response->assertStatus(200);
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->where('data.is_public', false)
->where('data.shareable_link', null)
);
}
public function test_update_endpoint_does_not_change_the_secret_of_a_public_report_if_it_is_set_to_public_again(): void
{
// Arrange
$data = $this->createUserWithPermission([
'reports:update',
]);
$report = Report::factory()->public()->forOrganization($data->organization)->create();
$secret = $report->share_secret;
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.reports.update', [$data->organization->getKey(), $report->getKey()]), [
'is_public' => true,
]);
// Assert
$report->refresh();
$this->assertTrue($report->is_public);
$this->assertSame($secret, $report->share_secret);
$response->assertStatus(200);
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->where('data.is_public', true)
->where('data.shareable_link', $report->getShareableLink())
);
}
public function test_update_endpoint_can_update_the_report_all_properties_set(): void
{
// Arrange
$data = $this->createUserWithPermission([
'reports:update',
]);
$report = Report::factory()->forOrganization($data->organization)->create();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.reports.update', [$data->organization->getKey(), $report->getKey()]), [
'name' => 'Updated Report',
'description' => 'Updated description',
'is_public' => true,
'public_until' => Carbon::now()->addDays(30)->toIso8601ZuluString(),
]);
// Assert
$response->assertStatus(200);
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->where('data.name', 'Updated Report')
->where('data.description', 'Updated description')
->where('data.is_public', true)
->where('data.properties.group', TimeEntryAggregationType::Project->value)
->where('data.properties.sub_group', TimeEntryAggregationType::Task->value)
);
}
public function test_show_endpoint_fails_if_user_has_no_permission_to_view_report(): void
{
// Arrange
$data = $this->createUserWithPermission();
$report = Report::factory()->forOrganization($data->organization)->create();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.reports.show', [$data->organization->getKey(), $report->getKey()]));
// Assert
$response->assertForbidden();
}
public function test_show_endpoint_fails_if_report_does_not_exist(): void
{
// Arrange
$data = $this->createUserWithPermission([
'reports:view',
]);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.reports.show', [$data->organization->getKey(), 1]));
// Assert
$response->assertNotFound();
}
public function test_show_endpoint_fails_if_report_does_not_belong_to_organization(): void
{
// Arrange
$data = $this->createUserWithPermission([
'reports:view',
]);
$report = Report::factory()->create();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.reports.show', [$data->organization->getKey(), $report->getKey()]));
// Assert
$response->assertForbidden();
}
public function test_show_endpoint_returns_detailed_report(): void
{
// Arrange
$data = $this->createUserWithPermission([
'reports:view',
]);
$report = Report::factory()->forOrganization($data->organization)->create();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.reports.show', [$data->organization->getKey(), $report->getKey()]));
// Assert
$response->assertStatus(200);
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->where('data.id', $report->getKey())
);
}
public function test_destroy_endpoint_fails_if_user_has_no_permission_to_delete_report(): void
{
// Arrange
$data = $this->createUserWithPermission();
$report = Report::factory()->forOrganization($data->organization)->create();
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.reports.destroy', [$data->organization->getKey(), $report->getKey()]));
// Assert
$response->assertForbidden();
}
public function test_destroy_endpoint_fails_if_report_belongs_to_another_organization(): void
{
// Arrange
$data = $this->createUserWithPermission([
'reports:delete',
]);
$report = Report::factory()->create();
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.reports.destroy', [$data->organization->getKey(), $report->getKey()]));
// Assert
$response->assertForbidden();
}
public function test_destroy_endpoint_fails_if_report_does_not_exist(): void
{
// Arrange
$data = $this->createUserWithPermission([
'reports:delete',
]);
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.reports.destroy', [$data->organization->getKey(), 1]));
// Assert
$response->assertNotFound();
}
public function test_destroy_endpoint_deletes_a_report(): void
{
// Arrange
$data = $this->createUserWithPermission([
'reports:delete',
]);
$report = Report::factory()->forOrganization($data->organization)->create();
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.reports.destroy', [$data->organization->getKey(), $report->getKey()]));
// Assert
$response->assertNoContent();
$this->assertDatabaseMissing(Report::class, [
'id' => $report->getKey(),
]);
}
}

View File

@@ -930,6 +930,25 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
$response->assertForbidden();
}
public function test_aggregate_endpoint_fails_if_request_has_sub_group_but_no_group(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:view:all',
]);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.aggregate', [
$data->organization->getKey(),
'sub_group' => TimeEntryAggregationType::Task->value,
]));
// Assert
$response->assertStatus(422);
$response->assertJsonValidationErrorFor('group');
}
public function test_aggregate_endpoint_works_for_user_with_only_access_to_own_time_entries(): void
{
// Arrange

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Model;
use App\Models\Organization;
use App\Models\Report;
use App\Service\ReportService;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\UsesClass;
#[CoversClass(Report::class)]
#[UsesClass(Report::class)]
class ReportModelTest extends ModelTestAbstract
{
public function test_it_belongs_to_a_organization(): void
{
// Arrange
$organization = Organization::factory()->create();
$report = Report::factory()->forOrganization($organization)->create();
// Act
$report->refresh();
$organizationRel = $report->organization;
// Assert
$this->assertNotNull($organizationRel);
$this->assertTrue($organizationRel->is($organization));
}
public function test_shareable_link_is_null_when_report_is_private_but_share_secret_exists(): void
{
// Arrange
$report = Report::factory()->private()->create([
'share_secret' => app(ReportService::class)->generateSecret(),
]);
// Act
$report->refresh();
// Assert
$this->assertNull($report->getShareableLink());
}
public function test_shareable_link_is_null_when_report_is_public_but_share_secret_is_null(): void
{
// Arrange
$report = Report::factory()->public()->create([
'share_secret' => null,
]);
// Act
$report->refresh();
// Assert
$this->assertNull($report->getShareableLink());
}
public function test_shareable_link_is_null_when_report_is_public(): void
{
// Arrange
$report = Report::factory()->public()->create();
// Act
$report->refresh();
// Assert
$this->assertNotNull($report->getShareableLink());
}
public function test_shareable_link_is_url_to_web_endpoint_when_report_is_public(): void
{
// Arrange
$report = Report::factory()->public()->create();
// Act
$report->refresh();
// Assert
$this->assertSame(url('/shared-report#'.$report->share_secret), $report->getShareableLink());
}
}