From 70f483b13bda3b0d71692cb610f39e8bb03095ec Mon Sep 17 00:00:00 2001 From: Gregor Vostrak Date: Mon, 5 Oct 2026 16:15:27 +0200 Subject: [PATCH] improve API docs --- .../Api/V1/InvitationController.php | 2 ++ .../Controllers/Api/V1/MemberController.php | 3 +++ .../Controllers/Api/V1/ReportController.php | 2 ++ .../Api/V1/TimeEntryController.php | 24 +++++++++++++++++- .../V1/Member/MemberUpdateRequest.php | 1 + .../OrganizationUpdateRequest.php | 1 + .../V1/Project/ProjectStoreRequest.php | 1 + .../V1/Project/ProjectUpdateRequest.php | 1 + .../ProjectMemberStoreRequest.php | 1 + .../ProjectMemberUpdateRequest.php | 1 + .../V1/TimeEntry/TimeEntryIndexRequest.php | 4 +-- .../V1/TimeEntry/TimeEntryStoreRequest.php | 4 +-- config/scramble.php | 25 ++++++++++++++++++- 13 files changed, 64 insertions(+), 6 deletions(-) diff --git a/app/Http/Controllers/Api/V1/InvitationController.php b/app/Http/Controllers/Api/V1/InvitationController.php index 6873e8fe..ff39d815 100644 --- a/app/Http/Controllers/Api/V1/InvitationController.php +++ b/app/Http/Controllers/Api/V1/InvitationController.php @@ -88,6 +88,8 @@ class InvitationController extends Controller /** * Remove a pending invitation * + * This revokes the invitation: the link in the invitation email stops working. Find the invitation ID with `GET /organizations/{organization}/invitations`. + * * @throws AuthorizationException * * @operationId removeInvitation diff --git a/app/Http/Controllers/Api/V1/MemberController.php b/app/Http/Controllers/Api/V1/MemberController.php index de2a2998..3b6132a1 100644 --- a/app/Http/Controllers/Api/V1/MemberController.php +++ b/app/Http/Controllers/Api/V1/MemberController.php @@ -145,6 +145,9 @@ class MemberController extends Controller /** * Merge one member into another * + * Only placeholder members (for example people created by an import) can be merged. All time entries and other data of the placeholder + * are reassigned to the member given in `member_id`, and the placeholder is removed. Find both member IDs with `GET /organizations/{organization}/members`. + * * @throws AuthorizationException * @throws OnlyPlaceholdersCanBeMergedIntoAnotherMember * @throws Throwable diff --git a/app/Http/Controllers/Api/V1/ReportController.php b/app/Http/Controllers/Api/V1/ReportController.php index 8d6052ae..710d1722 100644 --- a/app/Http/Controllers/Api/V1/ReportController.php +++ b/app/Http/Controllers/Api/V1/ReportController.php @@ -71,6 +71,8 @@ class ReportController extends Controller /** * Create report * + * A report is a saved set of filters. Set `is_public` to `true` to share it: the response then contains the `shareable_link` that can be opened without logging in. + * * @throws AuthorizationException * * @operationId createReport diff --git a/app/Http/Controllers/Api/V1/TimeEntryController.php b/app/Http/Controllers/Api/V1/TimeEntryController.php index 383e9ab9..b5fce2ac 100644 --- a/app/Http/Controllers/Api/V1/TimeEntryController.php +++ b/app/Http/Controllers/Api/V1/TimeEntryController.php @@ -109,9 +109,14 @@ class TimeEntryController extends Controller /** * Get time entries in organization * - * If you only need time entries for a specific user, you can filter by `member_id`. + * Without a member filter this returns the time entries of all members of the organization (for users who may view all time entries, such as owners and admins), not only your own. + * To get only your own time entries, pass your member ID as `member_id`. Your member ID is the `id` returned for this organization by `GET /v1/users/me/memberships`; it is not your user ID. * Users with the permission `time-entries:view:own` can only use this endpoint with their own member ID in the member_id filter. * + * The `start` and `end` filters both apply to the start time of an entry, in UTC. Convert the user's local day boundaries to UTC first. + * Results are paginated with `limit` (default 100, max 500) and `offset`; check `meta.total` and fetch further pages when needed. + * To find the running timer, use `active=true` (or `GET /v1/users/me/time-entries/active`). + * * @return TimeEntryCollection * * @throws AuthorizationException @@ -351,6 +356,11 @@ class TimeEntryController extends Controller * 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. * + * Durations are returned in `seconds` (divide by 3600 for hours) and amounts in `cost` as cents in the organization's currency (divide by 100 for money). + * Filter by member with `member_id`. Your member ID is the `id` returned for this organization by `GET /v1/users/me/memberships`; it is not your user ID. + * Array filters use the query format `client_ids[]=`. + * Example: billable hours per client for a period: `group=client&billable=true&start=...&end=...`. + * * @operationId getAggregatedTimeEntries * * @return array{ @@ -584,6 +594,12 @@ class TimeEntryController extends Controller /** * Create time entry * + * `billable` is not taken from the project. To match the web app, set it to the project's `is_billable` value (or `false` without a project). + * + * A member can only have one running time entry (an entry with `end` set to `null`). Creating a running entry while another one runs fails with `time_entry_still_running`. + * To start a new timer, first stop the running entry by updating its `end` to the current time, then create the new entry. + * To log past work, send both `start` and `end` (UTC). Create one entry per block of work. + * * @throws AuthorizationException * @throws TimeEntryStillRunningApiException * @@ -634,6 +650,8 @@ class TimeEntryController extends Controller /** * Update time entry * + * To stop a running timer, set `end` to the stop time (UTC). Times the user gives in their own timezone must be converted to UTC first. + * * @throws AuthorizationException|TimeEntryCanNotBeRestartedApiException * * @operationId updateTimeEntry @@ -702,6 +720,10 @@ class TimeEntryController extends Controller /** * Update multiple time entries * + * Applies the same `changes` to every entry in `ids`. To find the IDs, list entries with `GET /organizations/{organization}/time-entries` + * (filtered by `member_id`, the project and the other criteria), then send their IDs here. + * When `changes.project_id` moves entries to another project, also set `changes.task_id` to a task of the new project or to `null`, because tasks belong to a project. + * * @operationId updateMultipleTimeEntries * * @throws AuthorizationException diff --git a/app/Http/Requests/V1/Member/MemberUpdateRequest.php b/app/Http/Requests/V1/Member/MemberUpdateRequest.php index bdfc6c99..d6ef5e1b 100644 --- a/app/Http/Requests/V1/Member/MemberUpdateRequest.php +++ b/app/Http/Requests/V1/Member/MemberUpdateRequest.php @@ -27,6 +27,7 @@ class MemberUpdateRequest extends BaseFormRequest 'string', Rule::enum(Role::class), ], + // Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency) 'billable_rate' => array_merge( [ 'nullable', diff --git a/app/Http/Requests/V1/Organization/OrganizationUpdateRequest.php b/app/Http/Requests/V1/Organization/OrganizationUpdateRequest.php index 1316715c..6e024934 100644 --- a/app/Http/Requests/V1/Organization/OrganizationUpdateRequest.php +++ b/app/Http/Requests/V1/Organization/OrganizationUpdateRequest.php @@ -36,6 +36,7 @@ class OrganizationUpdateRequest extends BaseFormRequest 'string', new CurrencyRule, ], + // Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency) 'billable_rate' => array_merge( [ 'nullable', diff --git a/app/Http/Requests/V1/Project/ProjectStoreRequest.php b/app/Http/Requests/V1/Project/ProjectStoreRequest.php index 2e9471b7..54425c8a 100644 --- a/app/Http/Requests/V1/Project/ProjectStoreRequest.php +++ b/app/Http/Requests/V1/Project/ProjectStoreRequest.php @@ -55,6 +55,7 @@ class ProjectStoreRequest extends BaseFormRequest 'required', 'boolean', ], + // Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency) 'billable_rate' => array_merge( [ 'nullable', diff --git a/app/Http/Requests/V1/Project/ProjectUpdateRequest.php b/app/Http/Requests/V1/Project/ProjectUpdateRequest.php index 10474461..6670430f 100644 --- a/app/Http/Requests/V1/Project/ProjectUpdateRequest.php +++ b/app/Http/Requests/V1/Project/ProjectUpdateRequest.php @@ -68,6 +68,7 @@ class ProjectUpdateRequest extends BaseFormRequest return $builder->whereBelongsTo($this->organization, 'organization'); })->uuid(), ], + // Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency) 'billable_rate' => array_merge([ 'nullable', ], diff --git a/app/Http/Requests/V1/ProjectMember/ProjectMemberStoreRequest.php b/app/Http/Requests/V1/ProjectMember/ProjectMemberStoreRequest.php index 194dc591..3eb6585c 100644 --- a/app/Http/Requests/V1/ProjectMember/ProjectMemberStoreRequest.php +++ b/app/Http/Requests/V1/ProjectMember/ProjectMemberStoreRequest.php @@ -31,6 +31,7 @@ class ProjectMemberStoreRequest extends BaseFormRequest return $builder->whereBelongsTo($this->organization, 'organization'); })->uuid(), ], + // Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency) 'billable_rate' => array_merge( [ 'nullable', diff --git a/app/Http/Requests/V1/ProjectMember/ProjectMemberUpdateRequest.php b/app/Http/Requests/V1/ProjectMember/ProjectMemberUpdateRequest.php index d5479b5d..668ce7ed 100644 --- a/app/Http/Requests/V1/ProjectMember/ProjectMemberUpdateRequest.php +++ b/app/Http/Requests/V1/ProjectMember/ProjectMemberUpdateRequest.php @@ -21,6 +21,7 @@ class ProjectMemberUpdateRequest extends BaseFormRequest public function rules(): array { return [ + // Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency) 'billable_rate' => array_merge( [ 'nullable', diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php index 8bae1e86..d994307b 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php @@ -35,7 +35,7 @@ class TimeEntryIndexRequest extends BaseFormRequest public function rules(): array { return [ - // Filter by member ID + // Filter by member ID. Without it, users who may view all time entries (owners, admins) get the entries of every member; pass your own member ID (from GET /v1/users/me/memberships) to get only yours 'member_id' => [ 'string', ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder { @@ -155,7 +155,7 @@ class TimeEntryIndexRequest extends BaseFormRequest 'string', Rule::enum(TimeEntryType::class), ], - // Limit the number of returned time entries (default: 150) + // Limit the number of returned time entries (default: 100) 'limit' => [ 'integer', 'min:1', diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryStoreRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryStoreRequest.php index 18484f1d..7d94753c 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryStoreRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryStoreRequest.php @@ -32,7 +32,7 @@ class TimeEntryStoreRequest extends BaseFormRequest public function rules(): array { return [ - // ID of the organization member that the time entry should belong to + // ID of the organization member that the time entry should belong to (a member ID from GET /v1/users/me/memberships or the members list, not a user ID) 'member_id' => [ 'required', 'string', @@ -86,7 +86,7 @@ class TimeEntryStoreRequest extends BaseFormRequest 'date_format:Y-m-d\TH:i:s\Z', 'after_or_equal:start', ], - // Whether time entry is billable + // Whether time entry is billable. Not derived from the project: set it to the project's is_billable value to match the web app 'billable' => [ 'required', 'boolean', diff --git a/config/scramble.php b/config/scramble.php index bd78f3b3..f29f9c4b 100644 --- a/config/scramble.php +++ b/config/scramble.php @@ -28,7 +28,30 @@ return [ /* * Description rendered on the home page of the API documentation (`/docs/api`). */ - 'description' => '', + 'description' => <<<'MD' +## Getting started + +All organization endpoints live under `/v1/organizations/{organization}`, where `{organization}` is the organization's ID. Authenticate with `Authorization: Bearer ` and send `Accept: application/json`. + +**1. Find yourself.** Call `GET /v1/users/me/memberships`. Each membership contains the **organization ID** (use it as `{organization}` in paths) and your **member ID** in that organization (the membership `id`). Most endpoints filter by member ID, not by user ID. + +**2. Scope to your own data.** For owners and admins, `GET /time-entries` and `GET /time-entries/aggregate` return the whole organization's time entries unless you pass `member_id`. When acting for "me", always pass your member ID, both when reading and before changing entries. + +**3. Resolve names to IDs.** Look up projects, clients, tags, tasks and members by name with their list endpoints (`GET /projects`, `GET /clients`, `GET /tags`, `GET /tasks`, `GET /members`). Never guess IDs. + +**4. Use UTC.** All timestamps are sent and returned in UTC as `Y-m-d\TH:i:s\Z` (example: `2026-10-02T07:30:00Z`). Convert the user's local times and day boundaries to UTC before sending them. + +**5. Money is in cents.** Billable rates and costs are integers in cents of the organization's currency (`8000` means 80.00). + +## Common tasks + +- **Start a timer:** stop the running entry first (find it with `GET /v1/users/me/time-entries/active`, then `PUT` its `end`), then `POST /time-entries` with `start` and `end: null`. Only one entry can run per member. +- **Log past work:** `POST /time-entries` once per block with `member_id`, `start`, `end`, `project_id` and `billable` set to the project's `is_billable` (it is not derived automatically). +- **Fix or stop an entry:** `PUT /time-entries/{timeEntry}` with the new `start` or `end` in UTC. +- **Move entries to another project:** list them with `member_id` and filters, then `PATCH /time-entries` with their `ids` and `changes.project_id`, plus `changes.task_id` set to a task of the new project or `null`. +- **Totals and reports:** `GET /time-entries/aggregate` with `group` (for example `client` or `project`) and `start`/`end`; durations are in `seconds`, amounts in `cost` (cents). +- **Share a report:** `POST /reports` with `is_public: true` and use the returned `shareable_link`. +MD, ], /*