Added shareable reports

This commit is contained in:
Constantin Graf
2024-11-08 13:27:51 +01:00
committed by Constantin Graf
parent 9c82efdf07
commit 0860aa9d24
26 changed files with 986 additions and 107 deletions

View File

@@ -8,6 +8,7 @@ use App\Models\Report;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon;
use LogicException;
class ReportSetExpiredToPrivateCommand extends Command
{
@@ -44,7 +45,12 @@ class ReportSetExpiredToPrivateCommand extends Command
->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().')');
$publicUntil = $report->public_until;
if ($publicUntil === null) {
throw new LogicException('public_until should not be null');
}
$this->info('Make report "'.$report->name.'" ('.$report->getKey().') private, expired: '.
$publicUntil->toIso8601ZuluString().' ('.$publicUntil->diffForHumans().')');
$resetReports++;
if (! $dryRun) {
$report->is_public = false;

View File

@@ -4,10 +4,13 @@ declare(strict_types=1);
namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
use Illuminate\Support\Carbon;
enum Weekday: string
{
use LaravelEnumHelper;
case Monday = 'monday';
case Tuesday = 'tuesday';
case Wednesday = 'wednesday';

View File

@@ -102,6 +102,7 @@ class ProjectController extends Controller
$project->is_billable = (bool) $request->input('is_billable');
$project->billable_rate = $request->getBillableRate();
$project->client_id = $request->input('client_id');
$project->is_public = $request->getIsPublic();
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
$project->estimated_time = $request->getEstimatedTime();
}
@@ -127,6 +128,9 @@ class ProjectController extends Controller
if ($request->has('is_archived')) {
$project->archived_at = $request->getIsArchived() ? Carbon::now() : null;
}
if ($request->has('is_public')) {
$project->is_public = $request->boolean('is_public');
}
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
$project->estimated_time = $request->getEstimatedTime();
}

View File

@@ -4,9 +4,14 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1\Public;
use App\Enums\TimeEntryAggregationType;
use App\Http\Controllers\Api\V1\Controller;
use App\Http\Resources\V1\Report\DetailedReportResource;
use App\Http\Resources\V1\Report\DetailedWithDataReportResource;
use App\Models\Report;
use App\Models\TimeEntry;
use App\Service\Dto\ReportPropertiesDto;
use App\Service\TimeEntryAggregationService;
use App\Service\TimeEntryFilter;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\Request;
@@ -22,7 +27,7 @@ class ReportController extends Controller
*
* @operationId getPublicReport
*/
public function show(Request $request): DetailedReportResource
public function show(Request $request, TimeEntryAggregationService $timeEntryAggregationService): DetailedWithDataReportResource
{
$shareSecret = $request->header('X-Api-Key');
if (! is_string($shareSecret)) {
@@ -30,6 +35,9 @@ class ReportController extends Controller
}
$report = Report::query()
->with([
'organization',
])
->where('share_secret', '=', $shareSecret)
->where('is_public', '=', true)
->where(function (Builder $builder): void {
@@ -38,7 +46,45 @@ class ReportController extends Controller
->orWhere('public_until', '>', now());
})
->firstOrFail();
/** @var ReportPropertiesDto $properties */
$properties = $report->properties;
return new DetailedReportResource($report);
$timeEntriesQuery = TimeEntry::query()
->whereBelongsTo($report->organization, 'organization');
$filter = new TimeEntryFilter($timeEntriesQuery);
$filter->addStart($properties->start);
$filter->addEnd($properties->end);
$filter->addActive($properties->active);
$filter->addBillable($properties->billable);
$filter->addMemberIdsFilter($properties->memberIds?->toArray());
$filter->addProjectIdsFilter($properties->projectIds?->toArray());
$filter->addTagIdsFilter($properties->tagIds?->toArray());
$filter->addTaskIdsFilter($properties->taskIds?->toArray());
$filter->addClientIdsFilter($properties->clientIds?->toArray());
$timeEntriesQuery = $filter->get();
$data = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
$timeEntriesQuery->clone(),
$report->properties->group,
$report->properties->subGroup,
$report->properties->timezone,
$report->properties->weekStart,
false,
$report->properties->start,
$report->properties->end,
);
$historyData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
$timeEntriesQuery->clone(),
TimeEntryAggregationType::fromInterval($report->properties->historyGroup),
null,
$report->properties->timezone,
$report->properties->weekStart,
true,
$report->properties->start,
$report->properties->end,
);
return new DetailedWithDataReportResource($report, $data, $historyData);
}
}

View File

@@ -4,15 +4,17 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Enums\TimeEntryAggregationType;
use App\Enums\Weekday;
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;
use App\Service\ReportService;
use App\Service\TimezoneService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
@@ -32,6 +34,8 @@ class ReportController extends Controller
/**
* Get reports
*
* @return ReportCollection<ReportResource>
*
* @throws AuthorizationException
*
* @operationId getReports
@@ -69,9 +73,10 @@ class ReportController extends Controller
*
* @operationId createReport
*/
public function store(Organization $organization, ReportStoreRequest $request): DetailedReportResource
public function store(Organization $organization, ReportStoreRequest $request, TimezoneService $timezoneService, ReportService $reportService): DetailedReportResource
{
$this->checkPermission($organization, 'reports:create');
$user = $this->user();
$report = new Report;
$report->name = $request->getName();
@@ -79,11 +84,32 @@ class ReportController extends Controller
$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'));
$properties->group = $request->getPropertyGroup();
$properties->subGroup = $request->getPropertySubGroup();
$properties->historyGroup = $request->getPropertyHistoryGroup();
$properties->start = $request->getPropertyStart();
$properties->end = $request->getPropertyEnd();
$properties->active = $request->getPropertyActive();
$properties->setMemberIds($request->input('properties.member_ids', null));
$properties->billable = $request->getPropertyBillable();
$properties->setClientIds($request->input('properties.client_ids', null));
$properties->setProjectIds($request->input('properties.project_ids', null));
$properties->setTagIds($request->input('properties.tag_ids', null));
$properties->setTaskIds($request->input('properties.task_ids', null));
$properties->weekStart = $request->has('properties.week_start') ? Weekday::from($request->input('properties.week_start')) : $user->week_start;
$timezone = $user->timezone;
if ($request->has('properties.timezone')) {
if ($timezoneService->isValid($request->input('properties.timezone'))) {
$timezone = $request->input('properties.timezone');
}
if ($timezoneService->mapLegacyTimezone($request->input('properties.timezone')) !== null) {
$timezone = $timezoneService->mapLegacyTimezone($request->input('properties.timezone'));
}
}
$properties->timezone = $timezone;
$report->properties = $properties;
if ($isPublic) {
$report->share_secret = app(ReportService::class)->generateSecret();
$report->share_secret = $reportService->generateSecret();
$report->public_until = $request->getPublicUntil();
} else {
$report->share_secret = null;
@@ -102,7 +128,7 @@ class ReportController extends Controller
*
* @operationId updateReport
*/
public function update(Organization $organization, Report $report, ReportUpdateRequest $request): DetailedReportResource
public function update(Organization $organization, Report $report, ReportUpdateRequest $request, ReportService $reportService): DetailedReportResource
{
$this->checkPermission($organization, 'reports:update', $report);
@@ -116,7 +142,7 @@ class ReportController extends Controller
$isPublic = $request->getIsPublic();
$report->is_public = $isPublic;
if ($isPublic) {
$report->share_secret = app(ReportService::class)->generateSecret();
$report->share_secret = $reportService->generateSecret();
$report->public_until = $request->getPublicUntil();
} else {
$report->share_secret = null;

View File

@@ -68,9 +68,18 @@ class ProjectStoreRequest extends FormRequest
'min:0',
'max:2147483647',
],
// Whether the project is public
'is_public' => [
'boolean',
],
];
}
public function getIsPublic(): bool
{
return $this->has('is_public') && $this->boolean('is_public');
}
public function getBillableRate(): ?int
{
$input = $this->input('billable_rate');

View File

@@ -50,6 +50,9 @@ class ProjectUpdateRequest extends FormRequest
'is_archived' => [
'boolean',
],
'is_public' => [
'boolean',
],
'client_id' => [
'nullable',
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Report;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\Weekday;
use App\Models\Organization;
use Illuminate\Contracts\Validation\Rule as LegacyValidationRule;
use Illuminate\Contracts\Validation\ValidationRule;
@@ -49,11 +51,11 @@ class ReportStoreRequest extends FormRequest
'array',
],
'properties.start' => [
'nullable',
'required',
'date_format:Y-m-d\TH:i:s\Z',
],
'properties.end' => [
'nullable',
'required',
'date_format:Y-m-d\TH:i:s\Z',
],
'properties.active' => [
@@ -80,6 +82,7 @@ class ReportStoreRequest extends FormRequest
'string',
'uuid',
],
// Filter by project IDs, project IDs are OR combined
'properties.project_ids' => [
'nullable',
'array',
@@ -88,6 +91,7 @@ class ReportStoreRequest extends FormRequest
'string',
'uuid',
],
// Filter by tag IDs, tag IDs are OR combined
'properties.tag_ids' => [
'nullable',
'array',
@@ -108,11 +112,22 @@ class ReportStoreRequest extends FormRequest
'required',
Rule::enum(TimeEntryAggregationType::class),
],
'properties.sub_group' => [
'required',
Rule::enum(TimeEntryAggregationType::class),
],
'properties.history_group' => [
'required',
Rule::enum(TimeEntryAggregationTypeInterval::class),
],
'properties.week_start' => [
'nullable',
Rule::enum(Weekday::class),
],
'properties.timezone' => [
'nullable',
'timezone:all',
],
];
}
@@ -137,4 +152,57 @@ class ReportStoreRequest extends FormRequest
return $publicUntil === null ? null : Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $publicUntil);
}
public function getPropertyStart(): Carbon
{
$start = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('properties.start'));
if ($start === null) {
throw new \LogicException('Start date validation is not working');
}
return $start;
}
public function getPropertyEnd(): Carbon
{
$end = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('properties.end'));
if ($end === null) {
throw new \LogicException('End date validation is not working');
}
return $end;
}
public function getPropertyActive(): ?bool
{
if ($this->has('properties.active') && $this->input('properties.active') !== null) {
return (bool) $this->input('properties.active');
}
return null;
}
public function getPropertyBillable(): ?bool
{
if ($this->has('properties.billable') && $this->input('properties.billable') !== null) {
return (bool) $this->input('properties.billable');
}
return null;
}
public function getPropertyGroup(): TimeEntryAggregationType
{
return TimeEntryAggregationType::from($this->input('properties.group'));
}
public function getPropertySubGroup(): TimeEntryAggregationType
{
return TimeEntryAggregationType::from($this->input('properties.sub_group'));
}
public function getPropertyHistoryGroup(): TimeEntryAggregationTypeInterval
{
return TimeEntryAggregationTypeInterval::from($this->input('properties.history_group'));
}
}

View File

@@ -34,21 +34,23 @@ class TimeEntryAggregateExportRequest extends FormRequest
public function rules(): array
{
return [
// Data format of the export
'format' => [
'required',
'string',
Rule::enum(ExportFormat::class),
],
// Type of first grouping
'group' => [
'required',
Rule::enum(TimeEntryAggregationType::class),
],
// Type of second grouping
'sub_group' => [
'required',
Rule::enum(TimeEntryAggregationType::class),
],
// Type of grouping of the historic aggregation (time chart)
'history_group' => [
'required',
'nullable',
@@ -178,12 +180,22 @@ class TimeEntryAggregateExportRequest extends FormRequest
public function getStart(): Carbon
{
return Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('start'), 'UTC');
$start = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('start'), 'UTC');
if ($start === null) {
throw new \LogicException('Start date validation is not working');
}
return $start;
}
public function getEnd(): Carbon
{
return Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC');
$end = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC');
if ($end === null) {
throw new \LogicException('End date validation is not working');
}
return $end;
}
public function getFormatValue(): ExportFormat

View File

@@ -32,12 +32,13 @@ class TimeEntryAggregateRequest extends FormRequest
public function rules(): array
{
return [
// Type of first grouping
'group' => [
'nullable',
'required_with:sub_group',
Rule::enum(TimeEntryAggregationType::class),
],
// Type of second grouping
'sub_group' => [
'nullable',
Rule::enum(TimeEntryAggregationType::class),

View File

@@ -28,6 +28,8 @@ class PersonalMembershipResource extends BaseResource
'id' => $this->resource->organization->id,
/** @var string $name Name of organization */
'name' => $this->resource->organization->name,
/** @var string $currency Currency code (ISO 4217) of organization */
'currency' => $this->resource->organization->currency,
],
/** @var string $role Role */
'role' => $this->resource->role,

View File

@@ -45,6 +45,8 @@ class OrganizationResource extends BaseResource
'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,
/** @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,
/** @var string $currency Currency code (ISO 4217) */
'currency' => $this->resource->currency,
];
}
}

View File

@@ -48,6 +48,8 @@ class ProjectResource extends BaseResource
'estimated_time' => $this->resource->estimated_time,
/** @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,
/** @var bool $is_public Whether the project is public */
'is_public' => $this->resource->is_public,
];
}
}

View File

@@ -34,21 +34,35 @@ class DetailedReportResource extends BaseResource
/** @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' => [
/** @var string $group Type of first grouping */
'group' => $this->resource->properties->group->value,
/** @var string $sub_group Type of second grouping */
'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 string $history_group Type of grouping of the historic aggregation (time chart) */
'history_group' => $this->resource->properties->historyGroup->value,
/** @var string $start Start date of the report */
'start' => $this->resource->properties->start->toIso8601ZuluString(),
/** @var string $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,
/** @var array<string>|null $member_ids Filter by multiple member IDs, member IDs are OR combined */
'member_ids' => $this->resource->properties->memberIds?->toArray(),
/** @var bool|null $billable Filter by billable status */
'billable' => $this->resource->properties->billable,
/** @var array<string>|null $client_ids Filter by client IDs, client IDs are OR combined */
'client_ids' => $this->resource->properties->clientIds?->toArray(),
/** @var array<string>|null $project_ids Filter by project IDs, project IDs are OR combined */
'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(),
/** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */
'task_ids' => $this->resource->properties->taskIds?->toArray(),
],
/** @var string $created_at Date when the report was created */
'created_at' => $this->resource->created_at?->toIso8601ZuluString(),
/** @var string $updated_at Date when the report was last updated */
'updated_at' => $this->resource->updated_at?->toIso8601ZuluString(),
];
}
}

View File

@@ -0,0 +1,134 @@
<?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
*
* @phpstan-type Data array{
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* description: string|null,
* color: string|null,
* seconds: int,
* cost: int,
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* description: string|null,
* color: string|null,
* seconds: int,
* cost: int,
* grouped_type: null,
* grouped_data: null
* }>
* }>,
* seconds: int,
* cost: int
* }
*/
class DetailedWithDataReportResource extends BaseResource
{
/**
* @var Data
*/
private array $data;
/**
* @var Data
*/
private array $historyData;
/**
* @param Data $data
* @param Data $historyData
*/
public function __construct(Report $resource, array $data, array $historyData)
{
parent::__construct($resource);
$this->data = $data;
$this->historyData = $historyData;
}
/**
* Transform the resource into an array.
*
* @return array<string, string|bool|int|null|Data|array<string, string|bool|int|null|array<int, string>>>
*/
public function toArray(Request $request): array
{
return [
/** @var string $name Name */
'name' => $this->resource->name,
/** @var string|null $email Description */
'description' => $this->resource->description,
/** @var string|null $public_until Date until the report is public */
'public_until' => $this->resource->public_until?->toIso8601ZuluString(),
/** @var string $currency Currency code (ISO 4217) */
'currency' => $this->resource->organization->currency,
'properties' => [
/** @var string $group Type of first grouping */
'group' => $this->resource->properties->group->value,
/** @var string $sub_group Type of second grouping */
'sub_group' => $this->resource->properties->subGroup->value,
/** @var string $history_group Type of grouping of the historic aggregation (time chart) */
'history_group' => $this->resource->properties->historyGroup->value,
/** @var string $start Start date of the report */
'start' => $this->resource->properties->start->toIso8601ZuluString(),
/** @var string $end End date of the report */
'end' => $this->resource->properties->end->toIso8601ZuluString(),
],
/** @var array{
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* description: string|null,
* seconds: int,
* cost: int,
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* description: string|null,
* seconds: int,
* cost: int,
* grouped_type: null,
* grouped_data: null
* }>
* }>,
* seconds: int,
* cost: int
* } $data Aggregated data
*/
'data' => $this->data,
/** @var array{
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* description: string|null,
* seconds: int,
* cost: int,
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* description: string|null,
* seconds: int,
* cost: int,
* grouped_type: null,
* grouped_data: null
* }>
* }>,
* seconds: int,
* cost: int
* } $history_data Historic aggregated data
*/
'history_data' => $this->historyData,
];
}
}

View File

@@ -33,6 +33,10 @@ class ReportResource extends BaseResource
'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(),
/** @var string $created_at Date when the report was created */
'created_at' => $this->resource->created_at?->toIso8601ZuluString(),
/** @var string $updated_at Date when the report was last updated */
'updated_at' => $this->resource->updated_at?->toIso8601ZuluString(),
];
}
}

View File

@@ -22,6 +22,8 @@ use Illuminate\Support\Carbon;
* @property string|null $share_secret
* @property ReportPropertiesDto $properties
* @property-read Organization $organization
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
*
* @method static ReportFactory factory()
*/

View File

@@ -104,7 +104,7 @@ class TimeEntry extends Model implements AuditableContract
public function getClientIdComputed(): ?string
{
return $this->project_id === null ? null : $this->project->client_id;
return $this->project_id === null || $this->project === null ? null : $this->project->client_id;
}
/**

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Service\Dto;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\Weekday;
use Illuminate\Contracts\Database\Eloquent\Castable;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
@@ -14,13 +16,19 @@ use Illuminate\Support\Str;
class ReportPropertiesDto implements Castable
{
public ?TimeEntryAggregationType $group = null;
public TimeEntryAggregationType $group;
public ?TimeEntryAggregationType $subGroup = null;
public TimeEntryAggregationType $subGroup;
public ?Carbon $start = null;
public TimeEntryAggregationTypeInterval $historyGroup;
public ?Carbon $end = null;
public Weekday $weekStart;
public string $timezone;
public Carbon $start;
public Carbon $end;
public ?bool $active = null;
@@ -64,6 +72,9 @@ class ReportPropertiesDto implements Castable
private const array REQUIRED_PROPERTIES = [
'group',
'subGroup',
'historyGroup',
'weekStart',
'timezone',
'start',
'end',
'active',
@@ -93,38 +104,21 @@ class ReportPropertiesDto implements Castable
$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->memberIds = $data->memberIds !== null ? ReportPropertiesDto::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;
$dto->clientIds = $data->clientIds !== null ? ReportPropertiesDto::idArrayToCollection($data->clientIds) : null;
$dto->projectIds = $data->projectIds !== null ? ReportPropertiesDto::idArrayToCollection($data->projectIds) : null;
$dto->tagIds = $data->tagIds !== null ? ReportPropertiesDto::idArrayToCollection($data->tagIds) : null;
$dto->taskIds = $data->taskIds ? ReportPropertiesDto::idArrayToCollection($data->taskIds) : null;
$dto->group = TimeEntryAggregationType::from($data->group);
$dto->subGroup = TimeEntryAggregationType::from($data->subGroup);
$dto->historyGroup = TimeEntryAggregationTypeInterval::from($data->historyGroup);
$dto->weekStart = Weekday::from($data->weekStart);
$dto->timezone = $data->timezone;
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
*/
@@ -135,8 +129,8 @@ class ReportPropertiesDto implements Castable
}
$data = (object) [
'end' => $value->end?->toIso8601ZuluString(),
'start' => $value->start?->toIso8601ZuluString(),
'end' => $value->end->toIso8601ZuluString(),
'start' => $value->start->toIso8601ZuluString(),
'active' => $value->active,
'memberIds' => $value->memberIds?->toArray(),
'billable' => $value->billable,
@@ -144,8 +138,11 @@ class ReportPropertiesDto implements Castable
'projectIds' => $value->projectIds?->toArray(),
'tagIds' => $value->tagIds?->toArray(),
'taskIds' => $value->taskIds?->toArray(),
'group' => $value->group?->value,
'subGroup' => $value->subGroup?->value,
'group' => $value->group->value,
'subGroup' => $value->subGroup->value,
'historyGroup' => $value->historyGroup->value,
'weekStart' => $value->weekStart->value,
'timezone' => $value->timezone,
];
$jsonString = json_encode($data);
@@ -157,4 +154,64 @@ class ReportPropertiesDto implements Castable
}
};
}
/**
* @param array<mixed> $ids
* @return Collection<int, string>
*/
public static 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 array<mixed>|null $memberIds
*/
public function setMemberIds(?array $memberIds): void
{
$this->memberIds = $memberIds !== null ? ReportPropertiesDto::idArrayToCollection($memberIds) : null;
}
/**
* @param array<mixed>|null $clientIds
*/
public function setClientIds(?array $clientIds): void
{
$this->clientIds = $clientIds !== null ? ReportPropertiesDto::idArrayToCollection($clientIds) : null;
}
/**
* @param array<mixed>|null $projectIds
*/
public function setProjectIds(?array $projectIds): void
{
$this->projectIds = $projectIds !== null ? ReportPropertiesDto::idArrayToCollection($projectIds) : null;
}
/**
* @param array<mixed>|null $tagIds
*/
public function setTagIds(?array $tagIds): void
{
$this->tagIds = $tagIds !== null ? ReportPropertiesDto::idArrayToCollection($tagIds) : null;
}
/**
* @param array<mixed>|null $taskIds
*/
public function setTaskIds(?array $taskIds): void
{
$this->taskIds = $taskIds !== null ? ReportPropertiesDto::idArrayToCollection($taskIds) : null;
}
}

View File

@@ -146,12 +146,14 @@ class TimeEntryAggregationService
* grouped_data: null|array<array{
* key: string|null,
* description: string|null,
* color: string|null,
* seconds: int,
* cost: int,
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* description: string|null,
* color: string|null,
* seconds: int,
* cost: int,
* grouped_type: null,
@@ -180,15 +182,17 @@ class TimeEntryAggregationService
}
}
$descriptionMapGroup1 = $group1Type !== null ? $this->loadDescriptionMap($keysGroup1, $group1Type) : [];
$descriptionMapGroup2 = $group2Type !== null ? $this->loadDescriptionMap($keysGroup2, $group2Type) : [];
$descriptionMapGroup1 = $group1Type !== null ? $this->loadDescriptorsMap($keysGroup1, $group1Type) : [];
$descriptionMapGroup2 = $group2Type !== null ? $this->loadDescriptorsMap($keysGroup2, $group2Type) : [];
if ($aggregatedTimeEntries['grouped_data'] !== null) {
foreach ($aggregatedTimeEntries['grouped_data'] as $keyGroup1 => $group1) {
$aggregatedTimeEntries['grouped_data'][$keyGroup1]['description'] = $group1['key'] !== null ? ($descriptionMapGroup1[$group1['key']] ?? null) : null;
$aggregatedTimeEntries['grouped_data'][$keyGroup1]['description'] = $group1['key'] !== null ? ($descriptionMapGroup1[$group1['key']]['description'] ?? null) : null;
$aggregatedTimeEntries['grouped_data'][$keyGroup1]['color'] = $group1['key'] !== null ? ($descriptionMapGroup1[$group1['key']]['color'] ?? null) : null;
if ($aggregatedTimeEntries['grouped_data'][$keyGroup1]['grouped_data'] !== null) {
foreach ($aggregatedTimeEntries['grouped_data'][$keyGroup1]['grouped_data'] as $keyGroup2 => $group2) {
$aggregatedTimeEntries['grouped_data'][$keyGroup1]['grouped_data'][$keyGroup2]['description'] = $group2['key'] !== null ? ($descriptionMapGroup2[$group2['key']] ?? null) : null;
$aggregatedTimeEntries['grouped_data'][$keyGroup1]['grouped_data'][$keyGroup2]['description'] = $group2['key'] !== null ? ($descriptionMapGroup2[$group2['key']]['description'] ?? null) : null;
$aggregatedTimeEntries['grouped_data'][$keyGroup1]['grouped_data'][$keyGroup2]['color'] = $group2['key'] !== null ? ($descriptionMapGroup2[$group2['key']]['color'] ?? null) : null;
}
}
}
@@ -200,12 +204,14 @@ class TimeEntryAggregationService
* grouped_data: null|array<array{
* key: string|null,
* description: string|null,
* color: string|null,
* seconds: int,
* cost: int,
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* description: string|null,
* color: string|null,
* seconds: int,
* cost: int,
* grouped_type: null,
@@ -222,33 +228,61 @@ class TimeEntryAggregationService
/**
* @param array<int, string> $keys
* @return array<string, string>
* @return array<string, array{
* description: string,
* color: string|null
* }>
*/
private function loadDescriptionMap(array $keys, TimeEntryAggregationType $type): array
private function loadDescriptorsMap(array $keys, TimeEntryAggregationType $type): array
{
$descriptorMap = [];
if ($type === TimeEntryAggregationType::Client) {
return Client::query()
$clients = Client::query()
->whereIn('id', $keys)
->pluck('name', 'id')
->toArray();
->select('id', 'name')
->get();
foreach ($clients as $client) {
$descriptorMap[$client->id] = [
'description' => $client->name,
'color' => null,
];
}
} elseif ($type === TimeEntryAggregationType::User) {
return User::query()
$users = User::query()
->whereIn('id', $keys)
->pluck('name', 'id')
->toArray();
->select('id', 'name')
->get();
foreach ($users as $user) {
$descriptorMap[$user->id] = [
'description' => $user->name,
'color' => null,
];
}
} elseif ($type === TimeEntryAggregationType::Project) {
return Project::query()
$projects = Project::query()
->whereIn('id', $keys)
->pluck('name', 'id')
->toArray();
->select('id', 'name', 'color')
->get();
foreach ($projects as $project) {
$descriptorMap[$project->id] = [
'description' => $project->name,
'color' => $project->color,
];
}
} elseif ($type === TimeEntryAggregationType::Task) {
return Task::query()
$tasks = Task::query()
->whereIn('id', $keys)
->pluck('name', 'id')
->toArray();
} else {
return [];
->select('id', 'name')
->get();
foreach ($tasks as $task) {
$descriptorMap[$task->id] = [
'description' => $task->name,
'color' => null,
];
}
}
return $descriptorMap;
}
/**

View File

@@ -30,7 +30,17 @@ class TimeEntryFilter
if ($dateTime === null) {
return $this;
}
$this->builder->where('start', '<', Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $dateTime, 'UTC'));
$this->addEnd(Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $dateTime, 'UTC'));
return $this;
}
public function addEnd(?Carbon $end): self
{
if ($end === null) {
return $this;
}
$this->builder->where('start', '<', $end);
return $this;
}
@@ -40,7 +50,17 @@ class TimeEntryFilter
if ($dateTime === null) {
return $this;
}
$this->builder->where('start', '>', Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $dateTime, 'UTC'));
$this->addStart(Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $dateTime, 'UTC'));
return $this;
}
public function addStart(?Carbon $start): self
{
if ($start === null) {
return $this;
}
$this->builder->where('start', '>', $start);
return $this;
}
@@ -51,9 +71,21 @@ class TimeEntryFilter
return $this;
}
if ($active === 'true') {
$this->builder->whereNull('end');
$this->addActive(true);
} elseif ($active === 'false') {
$this->addActive(false);
} else {
Log::warning('Invalid active filter value', ['value' => $active]);
}
if ($active === 'false') {
return $this;
}
public function addActive(?bool $active): self
{
if ($active) {
$this->builder->whereNull('end');
} else {
$this->builder->whereNotNull('end');
}
@@ -89,9 +121,9 @@ class TimeEntryFilter
return $this;
}
if ($billable === 'true') {
$this->builder->where('billable', '=', true);
$this->addBillable(true);
} elseif ($billable === 'false') {
$this->builder->where('billable', '=', false);
$this->addBillable(false);
} else {
Log::warning('Invalid billable filter value', ['value' => $billable]);
}
@@ -99,6 +131,16 @@ class TimeEntryFilter
return $this;
}
public function addBillable(?bool $billable): self
{
if ($billable === null) {
return $this;
}
$this->builder->where('billable', '=', $billable);
return $this;
}
/**
* @param array<string>|null $clientIds
*/

View File

@@ -5,11 +5,14 @@ declare(strict_types=1);
namespace Database\Factories;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\Weekday;
use App\Models\Organization;
use App\Models\Report;
use App\Service\Dto\ReportPropertiesDto;
use App\Service\ReportService;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Carbon;
/**
* @extends Factory<Report>
@@ -24,8 +27,13 @@ class ReportFactory extends Factory
public function definition(): array
{
$reportDto = new ReportPropertiesDto;
$reportDto->start = Carbon::createFromDate($this->faker->dateTimeBetween('-1 year', '-1 month'));
$reportDto->end = Carbon::createFromDate($this->faker->dateTimeBetween('-1 month', 'now'));
$reportDto->group = TimeEntryAggregationType::Project;
$reportDto->subGroup = TimeEntryAggregationType::Task;
$reportDto->historyGroup = TimeEntryAggregationTypeInterval::Day;
$reportDto->weekStart = Weekday::from($this->faker->randomElement(Weekday::values()));
$reportDto->timezone = $this->faker->timezone();
return [
'name' => $this->faker->company(),

View File

@@ -87,18 +87,18 @@ Route::prefix('v1')->name('v1.')->group(static function (): void {
Route::delete('/project-members/{projectMember}', [ProjectMemberController::class, 'destroy'])->name('destroy');
});
// Time entry routes
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');
});
// Time entry routes
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');

View File

@@ -4,7 +4,18 @@ declare(strict_types=1);
namespace Tests\Unit\Endpoint\Api\V1\Public;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\Weekday;
use App\Models\Client;
use App\Models\Organization;
use App\Models\Project;
use App\Models\Report;
use App\Models\Tag;
use App\Models\Task;
use App\Models\TimeEntry;
use App\Service\Dto\ReportPropertiesDto;
use Illuminate\Support\Str;
use Tests\Unit\Endpoint\Api\V1\ApiEndpointTestAbstract;
class PublicReportEndpointTest extends ApiEndpointTestAbstract
@@ -68,9 +79,29 @@ class PublicReportEndpointTest extends ApiEndpointTestAbstract
public function test_show_returns_detailed_information_about_the_report(): void
{
// Arrange
$report = Report::factory()->public()->create([
$reportDto = new ReportPropertiesDto;
$organization = Organization::factory()->create();
$reportDto->start = now()->subDays(2);
$reportDto->end = now();
$reportDto->group = TimeEntryAggregationType::Project;
$reportDto->subGroup = TimeEntryAggregationType::Task;
$reportDto->historyGroup = TimeEntryAggregationTypeInterval::Day;
$reportDto->weekStart = Weekday::Monday;
$reportDto->timezone = 'Europe/Vienna';
$report = Report::factory()->forOrganization($organization)->public()->create([
'public_until' => null,
'properties' => $reportDto,
]);
$project = Project::factory()->forOrganization($organization)->create();
$task1 = Task::factory()->forOrganization($organization)->forProject($project)->create([
'id' => '1b0f1b32-0def-4932-8829-b68f52161987',
]);
$task2 = Task::factory()->forOrganization($organization)->forProject($project)->create([
'id' => '3c54796d-5ab4-41e1-8f30-aa61a0a919ae',
]);
TimeEntry::factory()->forOrganization($organization)->forTask($task1)->startWithDuration(now()->subDay(), 100)->create();
TimeEntry::factory()->forOrganization($organization)->forTask($task2)->startWithDuration(now()->subDay(), 100)->create();
TimeEntry::factory()->forOrganization($organization)->startWithDuration(now()->subDay(), 100)->create();
// Act
$response = $this->getJson(route('api.v1.public.reports.show'), [
@@ -79,12 +110,106 @@ class PublicReportEndpointTest extends ApiEndpointTestAbstract
// Assert
$response->assertOk();
$response->assertJsonFragment([
'id' => $report->id,
$response->assertExactJson([
'name' => $report->name,
'description' => $report->description,
'is_public' => $report->is_public,
'public_until' => $report->public_until?->toIso8601ZuluString(),
'currency' => $organization->currency,
'properties' => [
'group' => $reportDto->group->value,
'sub_group' => $reportDto->subGroup->value,
'history_group' => $reportDto->historyGroup->value,
'start' => $reportDto->start->toIso8601ZuluString(),
'end' => $reportDto->end->toIso8601ZuluString(),
],
'data' => [
'seconds' => 300,
'cost' => 0,
'grouped_type' => TimeEntryAggregationType::Project->value,
'grouped_data' => [
[
'key' => $project->id,
'seconds' => 200,
'cost' => 0,
'grouped_type' => TimeEntryAggregationType::Task->value,
'grouped_data' => [
[
'key' => $task1->id,
'seconds' => 100,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
'description' => $task1->name,
'color' => null,
],
[
'key' => $task2->id,
'seconds' => 100,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
'description' => $task2->name,
'color' => null,
],
],
'description' => $project->name,
'color' => $project->color,
],
[
'key' => null,
'seconds' => 100,
'cost' => 0,
'grouped_type' => TimeEntryAggregationType::Task->value,
'grouped_data' => [
[
'key' => null,
'seconds' => 100,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
'description' => null,
'color' => null,
],
],
'description' => null,
'color' => null,
],
],
],
'history_data' => [
'seconds' => 300,
'cost' => 0,
'grouped_type' => TimeEntryAggregationTypeInterval::Day->value,
'grouped_data' => [
[
'key' => now()->subDays(2)->toDateString(),
'seconds' => 0,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
'description' => null,
'color' => null,
],
[
'key' => now()->subDays(1)->toDateString(),
'seconds' => 300,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
'description' => null,
'color' => null,
],
[
'key' => now()->toDateString(),
'seconds' => 0,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
'description' => null,
'color' => null,
],
],
],
]);
}
@@ -103,11 +228,188 @@ class PublicReportEndpointTest extends ApiEndpointTestAbstract
// 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_all_available_filters(): void
{
// Arrange
$organization = Organization::factory()->create();
$client = Client::factory()->forOrganization($organization)->create();
$otherClient = Client::factory()->forOrganization($organization)->create();
$project = Project::factory()->forClient($client)->forOrganization($organization)->create();
$otherProject = Project::factory()->forOrganization($organization)->create();
$otherProjectWithClient = Project::factory()->forClient($client)->forOrganization($organization)->create();
$task = Task::factory()->forOrganization($organization)->forProject($project)->create();
$tag = Tag::factory()->forOrganization($organization)->create();
$otherTag = Tag::factory()->forOrganization($organization)->create();
// Match for all filters
TimeEntry::factory()->forOrganization($organization)
->forTask($task)
->billable()
->startWithDuration(now()->subDay(), 100)
->create([
'tags' => [$tag->getKey()],
]);
// No match for task filter
TimeEntry::factory()->forOrganization($organization)
->forProject($otherProject)
->startWithDuration(now()->subDay(), 100)
->create();
// No match for client filter
TimeEntry::factory()->forOrganization($organization)
->forProject($otherProjectWithClient)
->startWithDuration(now()->subDay(), 100)
->create();
$reportDto = new ReportPropertiesDto;
$reportDto->start = now()->subDays(2);
$reportDto->end = now();
$reportDto->group = TimeEntryAggregationType::Project;
$reportDto->subGroup = TimeEntryAggregationType::Task;
$reportDto->historyGroup = TimeEntryAggregationTypeInterval::Day;
$reportDto->weekStart = Weekday::Monday;
$reportDto->timezone = 'Europe/Vienna';
$reportDto->active = false;
$reportDto->billable = true;
$reportDto->setMemberIds(null);
$reportDto->setClientIds([$client->getKey()]);
$reportDto->setProjectIds([$project->getKey()]);
$reportDto->setTagIds([$tag->getKey()]);
$reportDto->setTaskIds([$task->getKey()]);
$report = Report::factory()->forOrganization($organization)->public()->create([
'public_until' => null,
'properties' => $reportDto,
]);
// Act
$response = $this->getJson(route('api.v1.public.reports.show'), [
'X-Api-Key' => $report->share_secret,
]);
// Assert
$response->assertOk();
$response->assertJson([
'name' => $report->name,
'description' => $report->description,
'public_until' => $report->public_until?->toIso8601ZuluString(),
'properties' => [
'group' => $reportDto->group->value,
'sub_group' => $reportDto->subGroup->value,
'history_group' => $reportDto->historyGroup->value,
'start' => $reportDto->start->toIso8601ZuluString(),
'end' => $reportDto->end->toIso8601ZuluString(),
],
'data' => [
'seconds' => 100,
'cost' => 0,
'grouped_type' => TimeEntryAggregationType::Project->value,
],
'history_data' => [
'seconds' => 100,
'cost' => 0,
'grouped_type' => TimeEntryAggregationTypeInterval::Day->value,
],
]);
}
public function test_if_the_resources_behind_the_filters_no_longer_exist_the_report_ignores_those_filters_but_this_does_not_increase_the_visible_data(): void
{
// Arrange
$organization = Organization::factory()->create();
$client = Client::factory()->forOrganization($organization)->create();
$project = Project::factory()->forClient($client)->forOrganization($organization)->create();
$task = Task::factory()->forOrganization($organization)->forProject($project)->create();
$tag = Tag::factory()->forOrganization($organization)->create();
TimeEntry::factory()->forOrganization($organization)
->forTask($task)
->billable()
->startWithDuration(now()->subDay(), 100)
->create([
'tags' => [$tag->getKey()],
]);
$reportDto = new ReportPropertiesDto;
$reportDto->start = now()->subDays(2);
$reportDto->end = now();
$reportDto->group = TimeEntryAggregationType::Project;
$reportDto->subGroup = TimeEntryAggregationType::Task;
$reportDto->historyGroup = TimeEntryAggregationTypeInterval::Day;
$reportDto->weekStart = Weekday::Monday;
$reportDto->timezone = 'Europe/Vienna';
$reportDto->setMemberIds([Str::uuid()->toString()]);
$reportDto->setClientIds([Str::uuid()->toString()]);
$reportDto->setProjectIds([Str::uuid()->toString()]);
$reportDto->setTagIds([Str::uuid()->toString()]);
$reportDto->setTaskIds([Str::uuid()->toString()]);
$report = Report::factory()->forOrganization($organization)->public()->create([
'public_until' => null,
'properties' => $reportDto,
]);
// Act
$response = $this->getJson(route('api.v1.public.reports.show'), [
'X-Api-Key' => $report->share_secret,
]);
// Assert
$response->assertOk();
$response->assertJson([
'name' => $report->name,
'description' => $report->description,
'public_until' => $report->public_until?->toIso8601ZuluString(),
'properties' => [
'group' => $reportDto->group->value,
'sub_group' => $reportDto->subGroup->value,
'history_group' => $reportDto->historyGroup->value,
'start' => $reportDto->start->toIso8601ZuluString(),
'end' => $reportDto->end->toIso8601ZuluString(),
],
'data' => [
'seconds' => 0,
'cost' => 0,
'grouped_type' => TimeEntryAggregationType::Project->value,
'grouped_data' => [],
],
'history_data' => [
'seconds' => 0,
'cost' => 0,
'grouped_type' => TimeEntryAggregationTypeInterval::Day->value,
'grouped_data' => [
[
'key' => now()->subDays(2)->toDateString(),
'seconds' => 0,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
'description' => null,
'color' => null,
],
[
'key' => now()->subDays(1)->toDateString(),
'seconds' => 0,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
'description' => null,
'color' => null,
],
[
'key' => now()->toDateString(),
'seconds' => 0,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
'description' => null,
'color' => null,
],
],
],
]);
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Tests\Unit\Endpoint\Api\V1;
use App\Enums\TimeEntryAggregationType;
use App\Enums\Weekday;
use App\Http\Controllers\Api\V1\ReportController;
use App\Models\Report;
use Illuminate\Support\Carbon;
@@ -70,6 +71,9 @@ class ReportEndpointTest extends ApiEndpointTestAbstract
'properties' => [
'group' => TimeEntryAggregationType::Project->value,
'sub_group' => TimeEntryAggregationType::Task->value,
'history_group' => TimeEntryAggregationType::Day->value,
'start' => Carbon::now()->subDays(30)->toIso8601ZuluString(),
'end' => Carbon::now()->toIso8601ZuluString(),
],
]);
@@ -92,6 +96,9 @@ class ReportEndpointTest extends ApiEndpointTestAbstract
'properties' => [
'group' => TimeEntryAggregationType::Project->value,
'sub_group' => TimeEntryAggregationType::Task->value,
'history_group' => TimeEntryAggregationType::Day->value,
'start' => Carbon::now()->subDays(30)->toIso8601ZuluString(),
'end' => Carbon::now()->toIso8601ZuluString(),
],
]);
@@ -117,7 +124,7 @@ class ReportEndpointTest extends ApiEndpointTestAbstract
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.reports.store', [$data->organization->getKey()]), [
$response = $this->withoutExceptionHandling()->postJson(route('api.v1.reports.store', [$data->organization->getKey()]), [
'name' => 'Test Report',
'description' => 'Test description',
'is_public' => true,
@@ -134,6 +141,9 @@ class ReportEndpointTest extends ApiEndpointTestAbstract
'task_ids' => [],
'group' => TimeEntryAggregationType::Project->value,
'sub_group' => TimeEntryAggregationType::Task->value,
'history_group' => TimeEntryAggregationType::Day->value,
'week_start' => Weekday::Monday->value,
'timezone' => 'Europe/Berlin',
],
]);
@@ -251,7 +261,7 @@ class ReportEndpointTest extends ApiEndpointTestAbstract
);
}
public function test_update_endpoint_can_set_a_report_to_public_which_generates_a_new_secret(): void
public function test_update_endpoint_can_set_a_report_from_private_to_public_which_generates_a_new_secret(): void
{
// Arrange
$data = $this->createUserWithPermission([
@@ -269,7 +279,7 @@ class ReportEndpointTest extends ApiEndpointTestAbstract
$report->refresh();
$this->assertTrue($report->is_public);
$this->assertNotNull($report->share_secret);
$response->assertStatus(200);
$this->assertResponseCode($response, 200);
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->where('data.is_public', true)
@@ -277,7 +287,7 @@ class ReportEndpointTest extends ApiEndpointTestAbstract
);
}
public function test_update_endpoint_can_set_a_report_to_private_which_resets_the_secret(): void
public function test_update_endpoint_can_set_a_report_from_public_to_private_which_resets_the_secret(): void
{
// Arrange
$data = $this->createUserWithPermission([

View File

@@ -318,4 +318,92 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
],
], $result);
}
public function test_aggregate_time_entries_by_client_and_project_with_filled_gaps(): void
{
// Arrange
$client1 = Client::factory()->create();
$client2 = Client::factory()->create();
$project1 = Project::factory()->forClient($client1)->create();
$project2 = Project::factory()->forClient($client2)->create();
$project3 = Project::factory()->create();
TimeEntry::factory()->startWithDuration(now(), 10)->forProject($project1)->create();
TimeEntry::factory()->startWithDuration(now(), 10)->forProject($project2)->create();
TimeEntry::factory()->startWithDuration(now(), 10)->forProject($project3)->create();
TimeEntry::factory()->startWithDuration(now(), 10)->create();
$query = TimeEntry::query();
// Act
$result = $this->service->getAggregatedTimeEntries(
$query,
TimeEntryAggregationType::Client,
TimeEntryAggregationType::Project,
'Europe/Vienna',
Weekday::Monday,
true,
null,
null
);
// Assert
$this->assertEqualsCanonicalizing([
'seconds' => 40,
'cost' => 0,
'grouped_type' => 'client',
'grouped_data' => [
[
'key' => null,
'seconds' => 20,
'cost' => 0,
'grouped_type' => 'project',
'grouped_data' => [
[
'key' => null,
'seconds' => 10,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
],
[
'key' => $project3->getKey(),
'seconds' => 10,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
],
],
],
[
'key' => $client1->getKey(),
'seconds' => 10,
'cost' => 0,
'grouped_type' => 'project',
'grouped_data' => [
[
'key' => $project1->getKey(),
'seconds' => 10,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
],
],
],
[
'key' => $client2->getKey(),
'seconds' => 10,
'cost' => 0,
'grouped_type' => 'project',
'grouped_data' => [
[
'key' => $project2->getKey(),
'seconds' => 10,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
],
],
],
],
], $result);
}
}