organization_id !== $organization->getKey()) { throw new AuthorizationException('Time entry does not belong to organization'); } } /** * Get all time entries in organization * * If you only need time entries for a specific user, you can filter by `user_id`. * Users with the permission `time-entries:view:own` can only use this endpoint with their own user ID in the user_id filter. * * @throws AuthorizationException * * @operationId getTimeEntries */ public function index(Organization $organization, TimeEntryIndexRequest $request): JsonResource { /** @var Member|null $member */ $member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null; if ($member !== null && $member->user_id === Auth::id()) { $this->checkPermission($organization, 'time-entries:view:own'); } else { $this->checkPermission($organization, 'time-entries:view:all'); } $timeEntriesQuery = TimeEntry::query() ->whereBelongsTo($organization, 'organization') ->orderBy('start', 'desc'); $filter = new TimeEntryFilter($timeEntriesQuery); $filter->addStartFilter($request->input('start')); $filter->addEndFilter($request->input('end')); $filter->addActiveFilter($request->input('active')); $filter->addMemberIdFilter($member); $filter->addMemberIdsFilter($request->input('member_ids')); $filter->addProjectIdsFilter($request->input('project_ids')); $filter->addTagIdsFilter($request->input('tag_ids')); $filter->addTaskIdsFilter($request->input('task_ids')); $filter->addClientIdsFilter($request->input('client_ids')); $filter->addBillableFilter($request->input('billable')); $limit = $request->has('limit') ? (int) $request->input('limit', 100) : 100; if ($limit > 1000) { $limit = 1000; } $timeEntriesQuery->limit($limit); $timeEntries = $timeEntriesQuery->get(); if ($timeEntries->count() === $limit && $request->getOnlyFullDates()) { $user = $this->user(); $timezone = app(TimezoneService::class)->getTimezoneFromUser($user); $lastDate = null; /** @var TimeEntry $timeEntry */ foreach ($timeEntries as $timeEntry) { if ($lastDate === null || abs($lastDate->diffInDays($timeEntry->start->toImmutable()->timezone($timezone)->startOfDay())) > 0) { $lastDate = $timeEntry->start->toImmutable()->timezone($timezone)->startOfDay(); } } $timeEntries = $timeEntries->filter(function (TimeEntry $timeEntry) use ($lastDate, $timezone): bool { return $timeEntry->start->toImmutable()->timezone($timezone)->toDateString() !== $lastDate->toDateString(); }); if ($timeEntries->count() === 0) { Log::warning('User has has more than '.$limit.' time entries on one date', [ 'date' => $lastDate->toDateString(), 'user_id' => $request->input('user_id'), 'auth_user_id' => Auth::id(), 'limit' => $limit, ]); $timeEntries = $timeEntriesQuery ->limit(5000) ->where('start', '>=', $lastDate->copy()->startOfDay()->utc()) ->where('start', '<=', $lastDate->copy()->endOfDay()->utc()) ->get(); } } return new TimeEntryCollection($timeEntries); } /** * Get aggregated time entries in organization * * This endpoint allows you to filter time entries and aggregate them by different criteria. * The parameters `group` and `sub_group` allow you to group the time entries by different criteria. * If the group parameters are all set to `null` or are all missing, the endpoint will aggregate all filtered time entries. * * @operationId getAggregatedTimeEntries * * @return array{ * data: array{ * grouped_type: string|null, * grouped_data: null|array * }>, * seconds: int, * cost: int * } * } * * @throws AuthorizationException */ public function aggregate(Organization $organization, TimeEntryAggregateRequest $request, TimeEntryAggregationService $aggregationService): array { /** @var Member|null $member */ $member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null; if ($member !== null && $member->user_id === Auth::id()) { $this->checkPermission($organization, 'time-entries:view:own'); } else { $this->checkPermission($organization, 'time-entries:view:all'); } $timeEntriesQuery = TimeEntry::query() ->whereBelongsTo($organization, 'organization'); $filter = new TimeEntryFilter($timeEntriesQuery); $filter->addEndFilter($request->input('end')); $filter->addStartFilter($request->input('start')); $filter->addActiveFilter($request->input('active')); $filter->addMemberIdFilter($member); $filter->addMemberIdsFilter($request->input('member_ids')); $filter->addProjectIdsFilter($request->input('project_ids')); $filter->addTagIdsFilter($request->input('tag_ids')); $filter->addTaskIdsFilter($request->input('task_ids')); $filter->addClientIdsFilter($request->input('client_ids')); $filter->addBillableFilter($request->input('billable')); $timeEntriesQuery = $filter->get(); $user = $this->user(); $group1Type = $request->getGroup(); $group2Type = $request->getSubGroup(); $aggregatedData = $aggregationService->getAggregatedTimeEntries( $timeEntriesQuery, $group1Type, $group2Type, $user->timezone, $user->week_start, $request->getFillGapsInTimeGroups(), $request->getStart(), $request->getEnd() ); return [ 'data' => $aggregatedData, ]; } /** * Create time entry * * @throws AuthorizationException * @throws TimeEntryStillRunningApiException * * @operationId createTimeEntry */ public function store(Organization $organization, TimeEntryStoreRequest $request): JsonResource { /** @var Member $member */ $member = Member::query()->findOrFail($request->input('member_id')); if ($member->user_id === Auth::id()) { $this->checkPermission($organization, 'time-entries:create:own'); } else { $this->checkPermission($organization, 'time-entries:create:all'); } if ($request->input('end') === null && TimeEntry::query()->whereBelongsTo($member, 'member')->where('end', null)->exists()) { throw new TimeEntryStillRunningApiException; } $client = $request->input('project_id') !== null ? Project::findOrFail((string) $request->input('project_id'))->client : null; $timeEntry = new TimeEntry; $timeEntry->fill($request->validated()); $timeEntry->client()->associate($client); $timeEntry->user_id = $member->user_id; $timeEntry->description = $request->input('description') ?? ''; $timeEntry->organization()->associate($organization); $timeEntry->setComputedAttributeValue('billable_rate'); $timeEntry->save(); return new TimeEntryResource($timeEntry); } /** * Update time entry * * @throws AuthorizationException|TimeEntryCanNotBeRestartedApiException * * @operationId updateTimeEntry */ public function update(Organization $organization, TimeEntry $timeEntry, TimeEntryUpdateRequest $request): JsonResource { /** @var Member|null $member */ $member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null; if ($timeEntry->member->user_id === Auth::id() && ($member === null || $member->user_id === Auth::id())) { $this->checkPermission($organization, 'time-entries:update:own'); } else { $this->checkPermission($organization, 'time-entries:update:all'); } if ($timeEntry->end !== null && $request->has('end') && $request->input('end') === null) { throw new TimeEntryCanNotBeRestartedApiException; } if ($request->has('project_id')) { $client = $request->input('project_id') !== null ? Project::findOrFail((string) $request->input('project_id'))->client : null; $timeEntry->client()->associate($client); } $timeEntry->fill($request->validated()); $timeEntry->description = $request->input('description', $timeEntry->description) ?? ''; $timeEntry->setComputedAttributeValue('billable_rate'); $timeEntry->save(); return new TimeEntryResource($timeEntry); } /** * Update multiple time entries * * @operationId updateMultipleTimeEntries * * @throws AuthorizationException */ public function updateMultiple(Organization $organization, TimeEntryUpdateMultipleRequest $request): JsonResponse { $this->checkAnyPermission($organization, ['time-entries:update:all', 'time-entries:update:own']); $canAccessAll = $this->hasPermission($organization, 'time-entries:update:all'); $ids = $request->validated('ids'); $timeEntries = TimeEntry::query() ->whereBelongsTo($organization, 'organization') ->whereIn('id', $ids) ->get(); $changes = $request->validated('changes'); if (isset($changes['member_id']) && ! $canAccessAll && $this->member($organization)->getKey() !== $changes['member_id']) { throw new AuthorizationException; } $client = null; $overwriteClient = false; if ($request->has('changes.project_id')) { $client = $request->input('changes.project_id') !== null ? Project::findOrFail((string) $request->input('changes.project_id'))->client : null; $overwriteClient = true; } $success = new Collection; $error = new Collection; foreach ($ids as $id) { /** @var TimeEntry|null $timeEntry */ $timeEntry = $timeEntries->firstWhere('id', $id); if ($timeEntry === null) { // Note: ID wrong or time entry in different organization $error->push($id); continue; } if (! $canAccessAll && $timeEntry->user_id !== Auth::id()) { $error->push($id); continue; } $timeEntry->fill($changes); if ($overwriteClient) { $timeEntry->client()->associate($client); } $timeEntry->setComputedAttributeValue('billable_rate'); $timeEntry->save(); $success->push($id); } return response()->json([ 'success' => $success->toArray(), 'error' => $error->toArray(), ]); } /** * Delete time entry * * @throws AuthorizationException * * @operationId deleteTimeEntry */ public function destroy(Organization $organization, TimeEntry $timeEntry): JsonResponse { if ($timeEntry->member->user_id === Auth::id()) { $this->checkPermission($organization, 'time-entries:delete:own', $timeEntry); } else { $this->checkPermission($organization, 'time-entries:delete:all', $timeEntry); } $timeEntry->delete(); return response() ->json(null, 204); } }