Skip to content

Commit

Permalink
Added shareable reports
Browse files Browse the repository at this point in the history
  • Loading branch information
korridor committed Oct 11, 2024
1 parent 2b1da88 commit 875403b
Show file tree
Hide file tree
Showing 30 changed files with 1,921 additions and 150 deletions.
61 changes: 61 additions & 0 deletions app/Console/Commands/Report/ReportSetExpiredToPrivateCommand.php
Original file line number Diff line number Diff line change
@@ -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;
}
}
44 changes: 44 additions & 0 deletions app/Http/Controllers/Api/V1/Public/ReportController.php
Original file line number Diff line number Diff line change
@@ -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);
}
}
146 changes: 146 additions & 0 deletions app/Http/Controllers/Api/V1/ReportController.php
Original file line number Diff line number Diff line change
@@ -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);
}
}
140 changes: 140 additions & 0 deletions app/Http/Requests/V1/Report/ReportStoreRequest.php
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading

0 comments on commit 875403b

Please sign in to comment.