Skip to content

feat: add project-management and user endpoints for agent use - #4

Open
yepzdk wants to merge 4 commits into
mainfrom
feature/mcp-pm-endpoints
Open

feat: add project-management and user endpoints for agent use#4
yepzdk wants to merge 4 commits into
mainfrom
feature/mcp-pm-endpoints

Conversation

@yepzdk

@yepzdk yepzdk commented Aug 11, 2026

Copy link
Copy Markdown

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

Method Path Op
GET /tickets/{id} read
PATCH /tickets/{id} write
GET/POST /tickets/{id}/comments read/write
GET /tickets/{id}/files read
GET /projects read
GET /projects/{id}/progress read
GET /projects/{id}/statuses read
GET /milestones read
GET/POST /timesheets read/write
GET /users read

Notable decisions

Writes bypass core's services — verified necessary, not assumed. Tickets::patch() throws a TypeError from an API-key request because it reads session('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 precedent createTicket() 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() and Tickets::getStateLabels() — are reused rather than reimplemented.

PATCH is genuinely partial. Only the fields present in the body are written; omitting a field never clears it. Verified: setting plannedHours/dueDate/tags/description, then patching only name, left all four intact along with status.

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). PATCH accepts NEW/INPROGRESS/DONE and resolves per project; /projects/{id}/statuses exposes the mapping so clients never hardcode ids.

Project progress is flattened to plain text. Core embeds an HTML <a> button in estimatedCompletionDate for the UI.

Attachments return metadata only. zp_file stores no content column, so bytes are structurally unreachable; the endpoint returns realName rather than the internal encName.

GET /users closes a usability gap, and deliberately narrows what "member" means. The ticket and timesheet endpoints identify a person by username but offered no way to discover one. Membership is read from explicit assignment (zp_relationuserproject) rather than core's isUserAssignedToProject(), which also returns true for admins and for psettings: all users — that rule would list every admin under every project and make the projects array useless for picking a username. Deactivated users and API service accounts are excluded, and UserData carries no password, session, 2FA or reset-token field.

routes.php now funnels every route through one helper that builds the controller and asserts an authenticated ApiUser, 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:

  • All 13 endpoints return 200/201 with the expected payloads
  • Full write cycle: create → PATCH status to done → log time → comment, each confirmed in the database
  • Partial-update preservation confirmed field by field (above)
  • Scoping: key granted [4] hitting /projects/3/progress403; list endpoints omit ungranted projects
  • Grants: read-only key on every write endpoint → 403; no key → 401
  • Attachments verified against a real zp_file row — metadata only, no content
  • Original GET/POST /tickets unchanged; Leantime UI and the APIData plugin unaffected

GET /users additionally 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 ungranted projectId is refused with 403 and carries no rows, that a granted projectId is allowed, and that no credential fields appear in any row. It fails when the grant filter is removed. Run inside the phpfpm container:

docker compose exec \
  -e ALL_KEY=<key with projects: all> \
  -e SCOPED_KEY=<key with projects: [N]> \
  -e SCOPED_PROJECT=N \
  phpfpm php app/Plugins/Databridge/tests/users_scope_test.php

Last run on this branch: all checks passed.

Reviewer notes

  • Pint --config .pint/pint.json reports 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.
  • New models follow the existing readonly + full-phpDoc style; test data was cleaned up after each check.
  • The plugin targets PHP 8.2+ (readonly class). Lint on the host may report parse errors if the host PHP is older — run php -l inside the phpfpm container (8.3).

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>
yepzdk and others added 2 commits August 11, 2026 15:05
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 jekuaitk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks very good! A couple small comments!

Comment thread Controllers/Api.php
// "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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yoda style this is not!

Comment thread Controllers/Api.php
Comment on lines +263 to +266
$ticket = $this->requireGrantedTicket($ticketId, $apiUser);
if ($ticket instanceof JsonResponse) {
return $ticket;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While this is indeed correct, it is not obvious to readers of the code that any JsonResponse is due to an issue with access.

Comment thread Services/Databridge.php
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'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand why this was changed

Comment thread Services/Databridge.php
{
foreach ($this->ticketRepository->getStateLabels($projectId) as $key => $label) {
if (isset($label['statusType']) && 'NEW' === $label['statusType']) {
if (isset($label['statusType']) && $label['statusType'] === 'NEW') {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand why this was changed

Comment thread Services/Databridge.php

if ($value === $date->format($format)) {
return 'Y-m-d' === $format ? $date->startOfDay() : $date;
return $format === 'Y-m-d' ? $date->startOfDay() : $date;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand why this was changed

Comment thread CHANGELOG.md

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀

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>
@yepzdk yepzdk changed the title feat: add project-management endpoints for agent use feat: add project-management and user endpoints for agent use Sep 2, 2026
@yepzdk
yepzdk requested a review from cableman September 2, 2026 08:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants