feat: add project-management and user endpoints for agent use - #4
Open
yepzdk wants to merge 4 commits into
Open
Conversation
Extends the API from two endpoints to twelve, covering what a project
manager needs: updating todos, tracking time, reading progress, and
following discussion.
Adds PATCH /tickets/{id} (partial update — only the fields sent are
written, so a caller can change one attribute without clearing the rest),
GET /tickets/{id}, GET+POST /timesheets, GET+POST /tickets/{id}/comments,
GET /tickets/{id}/files, GET /projects, GET /projects/{id}/progress,
GET /projects/{id}/statuses and GET /milestones.
Status is exchanged as NEW/INPROGRESS/DONE rather than an integer,
because status ids are configured per project and can be relabelled —
the local instance returns Danish labels. The statuses endpoint exposes
the mapping so clients never hardcode ids.
Writes go straight to the tables rather than through core's services.
Verified that core cannot be used here: Tickets::patch() throws a
TypeError from an API-key request because it reads session('userdata.id'),
and Comments::addComment() hardcodes the same. This follows the precedent
already set by createTicket, and carries the same consequence — no core
events or notifications fire, so each write is logged with the calling
key's name instead. The two core read paths that are session-free
(getProjectProgress, getStateLabels) are reused rather than reimplemented.
Project progress is returned as plain text; core renders the estimated
completion date as an HTML button for the UI. Attachments return metadata
only, never file contents.
routes.php now funnels every route through one helper that resolves the
controller and asserts an authenticated ApiUser, so the fail-loud check
cannot be forgotten as endpoints are added.
Co-authored-by: Claude <noreply@anthropic.com>
Markdownlint enforces aligned table pipes; the tables added with the project-management endpoints were not padded, and the extended endpoint table lost its alignment. Brings the file from 68 issues down to 22 — below the 29 it had before this branch. The remainder are pre-existing and left alone to keep the diff on topic. Co-authored-by: Claude <noreply@anthropic.com>
Leantime constrains timesheets with UNIQUE (userId, ticketId, workDate, kind), so booking the same work twice is already impossible. Uncaught, that protection surfaced as a 500 carrying the failed INSERT statement — table and column names included — which is both unactionable for a client and more detail than an API should hand back. Translates the violation into a 409 naming what already exists. That makes the endpoint safe to retry, which matters because a client whose request times out cannot tell whether the write landed; repeating it can never double-book the hours. Found while smoke-testing from Claude Desktop, where a log_time call timed out with no result. The audit log confirmed the write never happened, but the ambiguity is the point: without this the correct move after a timeout was unclear. Co-authored-by: Claude <noreply@anthropic.com>
jekuaitk
reviewed
Aug 24, 2026
jekuaitk
left a comment
Contributor
There was a problem hiding this comment.
Looks very good! A couple small comments!
| // "archive" — same value). Core hides it from every listing, so a ticket created | ||
| // there would be invisible. | ||
| if (-1 === (int) $project->state) { | ||
| if ((int) $project->state === -1) { |
Comment on lines
+263
to
+266
| $ticket = $this->requireGrantedTicket($ticketId, $apiUser); | ||
| if ($ticket instanceof JsonResponse) { | ||
| return $ticket; | ||
| } |
Contributor
There was a problem hiding this comment.
While this is indeed correct, it is not obvious to readers of the code that any JsonResponse is due to an issue with access.
| private function getCarbonFromDatabaseValue(mixed $value): ?CarbonImmutable | ||
| { | ||
| return null !== $value && '0000-00-00 00:00:00' !== $value | ||
| return null !== $value && $value !== '0000-00-00 00:00:00' |
Contributor
There was a problem hiding this comment.
I don't understand why this was changed
| { | ||
| foreach ($this->ticketRepository->getStateLabels($projectId) as $key => $label) { | ||
| if (isset($label['statusType']) && 'NEW' === $label['statusType']) { | ||
| if (isset($label['statusType']) && $label['statusType'] === 'NEW') { |
Contributor
There was a problem hiding this comment.
I don't understand why this was changed
|
|
||
| if ($value === $date->format($format)) { | ||
| return 'Y-m-d' === $format ? $date->startOfDay() : $date; | ||
| return $format === 'Y-m-d' ? $date->startOfDay() : $date; |
Contributor
There was a problem hiding this comment.
I don't understand why this was changed
Lists the active users assigned to the calling key's granted projects, with their username, name, job title, department and project ids. The ticket and timesheet endpoints identify a person by username but offered no way to discover one; this closes that gap. Scoped like every other endpoint: each row's projects array is filtered to the key's own grant, so it never reveals that a user also works on a project the key cannot see, and an ungranted projectId returns 403 rather than an answer. Deactivated users and API service accounts are excluded. Membership is explicit assignment (zp_relationuserproject) rather than core's isUserAssignedToProject(), which also returns true for admins and for psettings 'all' projects — that rule would list every admin under every project and make the projects array useless for picking a username. Adds tests/users_scope_test.php covering the scoping and asserting no credential fields are exposed. Verified to fail when the grant filter is removed. Co-authored-by: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Grows the API from 2 endpoints to 13, covering what a project manager needs: updating todos, tracking time, reading progress, following discussion, and resolving a person to a username.
Paired with leantime-mcp#2, which exposes these as MCP tools. Review this one first — the tools call straight into this controller.
Endpoints added
GET/tickets/{id}PATCH/tickets/{id}GET/POST/tickets/{id}/commentsGET/tickets/{id}/filesGET/projectsGET/projects/{id}/progressGET/projects/{id}/statusesGET/milestonesGET/POST/timesheetsGET/usersNotable decisions
Writes bypass core's services — verified necessary, not assumed.
Tickets::patch()throws aTypeErrorfrom an API-key request because it readssession('userdata.id'), which is null;Comments::addComment()hardcodes the same. Probed through a real HTTP request rather than the CLI, since the CLI lacks a bound request and would have given a misleading error. This follows the precedentcreateTicket()already set, and carries the same consequence: no core events or notifications fire, so each write is logged with the calling key's name instead. Documented in the README rather than left as a surprise.The two core read paths that are session-free —
Projects::getProjectProgress()andTickets::getStateLabels()— are reused rather than reimplemented.PATCHis genuinely partial. Only the fields present in the body are written; omitting a field never clears it. Verified: settingplannedHours/dueDate/tags/description, then patching onlyname, left all four intact along withstatus.Status is exchanged as a type, not an id. Status ints are configured per project and can be relabelled — this instance returns Danish (
Ny,Under udførelse).PATCHacceptsNEW/INPROGRESS/DONEand resolves per project;/projects/{id}/statusesexposes the mapping so clients never hardcode ids.Project progress is flattened to plain text. Core embeds an HTML
<a>button inestimatedCompletionDatefor the UI.Attachments return metadata only.
zp_filestores no content column, so bytes are structurally unreachable; the endpoint returnsrealNamerather than the internalencName.GET /userscloses a usability gap, and deliberately narrows what "member" means. The ticket and timesheet endpoints identify a person byusernamebut offered no way to discover one. Membership is read from explicit assignment (zp_relationuserproject) rather than core'sisUserAssignedToProject(), which also returns true for admins and forpsettings: allusers — that rule would list every admin under every project and make theprojectsarray useless for picking a username. Deactivated users and API service accounts are excluded, andUserDatacarries no password, session, 2FA or reset-token field.routes.phpnow funnels every route through one helper that builds the controller and asserts an authenticatedApiUser, keeping the existing fail-loud property while removing the per-route repetition — so the check cannot be forgotten as endpoints are added.Verification
Against a local instance, with a
[read, write]key granted project 4 and a second[read]-only key granted project 3:PATCHstatus todone→ log time → comment, each confirmed in the database[4]hitting/projects/3/progress→403; list endpoints omit ungranted projects403; no key →401zp_filerow — metadata only, no contentGET/POST /ticketsunchanged; Leantime UI and the APIData plugin unaffectedGET /usersadditionally has an automated self-check,tests/users_scope_test.php, which drives the real endpoint over HTTP with one unrestricted key and one key scoped to a single project. It asserts that the scoped key sees no project outside its grant, that an ungrantedprojectIdis refused with403and carries no rows, that a grantedprojectIdis allowed, and that no credential fields appear in any row. It fails when the grant filter is removed. Run inside the phpfpm container:Last run on this branch: all checks passed.
Reviewer notes
Pint --config .pint/pint.jsonreports the same 6 files failing before and after this branch — the plugin's existing Yoda style predates it, so this adds no new failures and I did not reformat unrelated files.readonly+ full-phpDoc style; test data was cleaned up after each check.readonly class). Lint on the host may report parse errors if the host PHP is older — runphp -linside the phpfpm container (8.3).