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