Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
f66e865
Merge branch 'main' into develop
tuj Mar 6, 2026
2998f43
Entries referencing deleted users or tickets no longer fail the whole
tuj Jul 29, 2026
cb0fdb4
docs: updated changelog
tuj Jul 30, 2026
6961e01
feat: validated request parameters, so malformed input answers 400 in…
tuj Jul 30, 2026
54c9ddf
chore: removed economics fro gitignore
tuj Aug 4, 2026
fe6f089
feat: own the sync watermark in an itk_data_api_modified column
tuj Aug 5, 2026
9337943
fix: addressed review comments on PR-18
tuj Aug 11, 2026
2f9cb04
ci: ran pull request tests on the itkdev image directly
tuj Aug 11, 2026
db81c43
ci: set the compose user via a workflow env variable
tuj Aug 11, 2026
2b7130a
fix: addressed review feedback on request parameter validation
tuj Aug 11, 2026
bde9422
Merge branch 'feature/8000-sync-errors' into feature/8000-input-valid…
tuj Aug 11, 2026
fe5acc9
Merge branch 'feature/8000-sync-errors' into feature/8000-modified-co…
tuj Aug 11, 2026
1f1f3c3
fix: addressed review comments on PR-20
tuj Aug 14, 2026
8914a03
Merge pull request #18 from ITK-Leantime/feature/8000-sync-errors
tuj Aug 15, 2026
d226c3a
Merge pull request #19 from ITK-Leantime/feature/8000-input-validation
tuj Aug 15, 2026
42d5750
Fixed merge
tuj Aug 15, 2026
6debcae
Merge pull request #20 from ITK-Leantime/feature/8000-modified-column
tuj Aug 18, 2026
db80f66
feat: paginated the deleted endpoint and narrowed it to one type
tuj Aug 19, 2026
0e1f971
feat: renamed the deleted endpoint's deleted parameter to deletedAfter
tuj Aug 19, 2026
f472e14
fix: addressed review comments on PR-21
tuj Aug 19, 2026
c0163cb
Merge pull request #21 from ITK-Leantime/feature/8000-deleted-pagination
tuj Aug 20, 2026
7ce39ea
docs: updated changelog
tuj Aug 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(task test:*)",
"Bash(task composer:*)",
"Bash(task lint:*)"
]
}
}
26 changes: 23 additions & 3 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
@@ -1,17 +1,37 @@
on: pull_request

name: Review

# The checkout belongs to the runner, so the container writes to the bind mount
# as `runner` rather than as the image's `deploy` user.
env:
COMPOSE_USER: runner

jobs:
changelog:
runs-on: ubuntu-latest
name: Changelog should be updated
strategy:
fail-fast: false
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v7

- name: Git fetch
run: git fetch

- name: Check that changelog has been updated.
run: git diff --exit-code origin/${{ github.base_ref }} -- CHANGELOG.md && exit 1 || exit 0

test:
runs-on: ubuntu-latest
name: Unit tests
steps:
- name: Checkout
uses: actions/checkout@v7

# Tests run in the project's own compose stack, so the PHP version comes
# from compose.yml rather than being repeated here.
- name: Install dependencies
run: docker compose run --rm --no-deps phpfpm composer install --no-interaction --prefer-dist --no-progress

- name: Run tests
run: docker compose run --rm --no-deps phpfpm vendor/bin/phpunit
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7

- name: Build and create release
env:
Expand All @@ -34,7 +34,7 @@ jobs:
release_name="release-${tag_name}"
fi

docker compose run --user root --rm php bin/create-release "$tag_name"
docker compose run --user root --rm php-release bin/create-release "$tag_name"

# Delete release if it already exists.
gh release view "$release_name" > /dev/null 2>&1 && gh release delete "$release_name" --yes
Expand Down
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
vendor
.DS_Store
*.cache
composer.lock

# Release artifacts
release/
Expand Down
36 changes: 35 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,39 @@

## [Unreleased]

## [1.0.0] - 2026-08-21

* [PR-21](https://github.com/ITK-Leantime/data-api/pull/21)
* Paginated the deleted endpoint with `start` and `limit`, so a response is bounded like the entity endpoints instead of carrying the whole deletion history.
* Added `deletionId` to the deleted results — the deletion's own id, which the results are ordered and paged on, as opposed to `id`, which is the deleted entity's.
* Replaced `types` with a required singular `type` on the deleted endpoint, since a request now answers with one type's page; `results` is a flat list rather than an object keyed by type.
* Renamed the deleted endpoint's `deleted` parameter to `deletedAfter`, matching `modifiedAfter` on the entity endpoints. The old name answers 400 rather than being ignored, which would have returned the whole deletion history to a caller that believes it asked for a window.
* Answered the retired `types` parameter with a 400 naming `type`, instead of the generic "type is required" a caller sending it would otherwise get.
* Documented paging against the limit in the response rather than the one requested, since a limit above the cap is answered with the cap, and documented that `start` is a watermark rather than a gap-free cursor across sync runs.
* [PR-20](https://github.com/ITK-Leantime/data-api/pull/20)
* Added a plugin owned `itk_data_api_modified` column, maintained by database triggers, on projects, tickets, timesheets and users, so no write path can leave the sync watermark behind.
* Changed `modifiedAfter` to filter on that column, so edits to existing tickets and milestones are no longer missed and time logged from the weekly grid is picked up.
* Added `modified` to the users endpoint.
* Changed the deletion triggers to stamp `dateDeleted` in UTC, so `deleted` filters against the same clock the responses are read in.
* Moved the schema handling into a SchemaRepository, executing one statement at a time so installation reports failures instead of swallowing them, and made installing idempotent.
* Dropped the `dateDeleted` default on the deletion tables, so a row inserted without a trigger in place is left null rather than stamped with the server's local time.
* [PR-19](https://github.com/ITK-Leantime/data-api/pull/19)
* Validated request parameters, so malformed input answers 400 with a reason instead of failing with a 500.
* Rejected a limit below 1, which previously dropped the LIMIT clause and returned every row, and capped limit at 1000.
* Accepted comma separated ids, projectIds and types, since the endpoints are documented as GET with query parameters.
* Required types on the deleted endpoint, so a bare request answers 400 instead of returning every deleted id ever recorded, and stopped an unknown type reaching the error page.
* Fixed an empty projectIds list dropping the filter, which answered with every row instead of none.
* Trimmed whitespace around ids, projectIds and types elements sent in array form.
* Renamed InvalidRequestException to BadRequestException, matching the 400 it turns into.
* [PR-18](https://github.com/ITK-Leantime/data-api/pull/18)
* Allowed null values in API models, so entries referencing deleted users or deleted tickets no longer fail the whole request.
* Added userId to timesheets, so hours logged by a deleted user stay attributable.
* Stopped resolving ticket status against the session's project when a ticket has no project.
* Allowed a missing worker name, and stopped returning a whitespace-only name for a worker without one.
* Looked up ticket status labels once per project instead of once per ticket.
* Pinned the development dependencies to the Leantime release the plugin targets.
* Added PHPUnit test setup and a Taskfile for running it, and ran the tests in the project's Docker Compose stack on pull requests.

## [0.1.2] - 2026-03-06

* [PR-12](https://github.com/ITK-Leantime/data-api/pull/12)
Expand All @@ -28,7 +61,8 @@
* [PR-2](https://github.com/ITK-Leantime/data-api/pull/2)
* Initial release

[Unreleased]: https://github.com/ITK-Leantime/data-api/compare/0.1.2...HEAD
[Unreleased]: https://github.com/ITK-Leantime/data-api/compare/1.0.0...HEAD
[1.0.0]: https://github.com/ITK-Leantime/data-api/compare/0.1.2...1.0.0
[0.1.2]: https://github.com/ITK-Leantime/data-api/compare/0.1.1...0.1.2
[0.1.1]: https://github.com/ITK-Leantime/data-api/compare/0.1.0...0.1.1
[0.1.0]: https://github.com/ITK-Leantime/data-api/compare/0.0.2...0.1.0
Expand Down
74 changes: 39 additions & 35 deletions Controllers/API.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
namespace Leantime\Plugins\APIData\Controllers;

use Leantime\Core\Controller\Controller;
use Leantime\Plugins\APIData\Model\BadRequestException;
use Leantime\Plugins\APIData\Model\DeletedRequestParameters;
use Leantime\Plugins\APIData\Model\RequestParameters;
use Leantime\Plugins\APIData\Model\ResponseData;
use Leantime\Plugins\APIData\Services\APIData;
use Symfony\Component\HttpFoundation\JsonResponse;
Expand All @@ -21,78 +24,79 @@ public function init(APIData $dataAPIService): void

public function deleted(array $input): JsonResponse
{
return new JsonResponse($this->getDeleted($input));
return $this->respond(fn () => $this->getDeleted($input));
}

public function projects(array $input): JsonResponse
{
return new JsonResponse($this->getResults($input, APIData::TYPE_PROJECTS));
return $this->respond(fn () => $this->getResults($input, APIData::TYPE_PROJECTS));
}

public function milestones(array $input): JsonResponse
{
return new JsonResponse($this->getResults($input, APIData::TYPE_MILESTONES));
return $this->respond(fn () => $this->getResults($input, APIData::TYPE_MILESTONES));
}

public function tickets(array $input): JsonResponse
{
return new JsonResponse($this->getResults($input, APIData::TYPE_TICKETS));
return $this->respond(fn () => $this->getResults($input, APIData::TYPE_TICKETS));
}

public function timesheets(array $input): JsonResponse
{
return new JsonResponse($this->getResults($input, APIData::TYPE_TIMESHEETS));
return $this->respond(fn () => $this->getResults($input, APIData::TYPE_TIMESHEETS));
}

public function workers(array $input): JsonResponse
{
return new JsonResponse($this->getResults($input, APIData::TYPE_WORKERS));
return $this->respond(fn () => $this->getResults($input, APIData::TYPE_WORKERS));
}

private function getDeleted(array $input): array
/**
* A parameter the caller got wrong is their error, not ours, so it answers
* 400 with the reason instead of Leantime's 500 error page.
*/
private function respond(callable $resolve): JsonResponse
{
$types = $input['types'];
$deleted = $input['deleted'] ?? null;
try {
return new JsonResponse($resolve());
} catch (BadRequestException $exception) {
return new JsonResponse(['error' => $exception->getMessage()], JsonResponse::HTTP_BAD_REQUEST);
}
}

$deletedEntries = [];
$count = 0;
private function getDeleted(array $input): array
{
$parameters = DeletedRequestParameters::fromInput($input);

foreach ($types as $type) {
$deletedEntries[$type] = $this->dataAPIService->getDeleted($type, $deleted);
$count = $count + count($deletedEntries[$type]);
}
$results = $this->dataAPIService->getDeleted(
$parameters->type,
$parameters->start,
$parameters->limit,
$parameters->deletedAfter,
);

return (new ResponseData(
['types' => $types],
$count,
$deletedEntries,
$parameters->toArray(),
count($results),
$results,
))->toArray();
}

private function getResults(array $input, string $type): array
{
$start = (int) ($input['start'] ?? 0);
$limit = (int) ($input['limit'] ?? 100);
$modifiedAfter = $input['modifiedAfter'] ?? null;
$ids = $input['ids'] ?? null;
$projectIds = $input['projectIds'] ?? null;
$parameters = RequestParameters::fromInput($input);

$results = match ($type) {
APIData::TYPE_PROJECTS => $this->dataAPIService->getProjects($start, $limit, $modifiedAfter, $ids),
APIData::TYPE_MILESTONES => $this->dataAPIService->getMilestones($start, $limit, $modifiedAfter, $ids, $projectIds),
APIData::TYPE_TICKETS => $this->dataAPIService->getTickets($start, $limit, $modifiedAfter, $ids, $projectIds),
APIData::TYPE_TIMESHEETS => $this->dataAPIService->getTimesheets($start, $limit, $modifiedAfter, $ids, $projectIds),
APIData::TYPE_WORKERS => $this->dataAPIService->getWorkers($start, $limit, $modifiedAfter, $ids),
APIData::TYPE_PROJECTS => $this->dataAPIService->getProjects($parameters->start, $parameters->limit, $parameters->modifiedAfter, $parameters->ids),
APIData::TYPE_MILESTONES => $this->dataAPIService->getMilestones($parameters->start, $parameters->limit, $parameters->modifiedAfter, $parameters->ids, $parameters->projectIds),
APIData::TYPE_TICKETS => $this->dataAPIService->getTickets($parameters->start, $parameters->limit, $parameters->modifiedAfter, $parameters->ids, $parameters->projectIds),
APIData::TYPE_TIMESHEETS => $this->dataAPIService->getTimesheets($parameters->start, $parameters->limit, $parameters->modifiedAfter, $parameters->ids, $parameters->projectIds),
APIData::TYPE_WORKERS => $this->dataAPIService->getWorkers($parameters->start, $parameters->limit, $parameters->modifiedAfter, $parameters->ids),
};

return (new ResponseData(
[
'start' => $start,
'limit' => $limit,
'modifiedAfter' => $modifiedAfter,
'ids' => $ids,
'projectIds' => $projectIds,
],
$parameters->toArray(),
count($results),
$results,
))->toArray();
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ FROM itkdev/php8.3-fpm:latest

USER root

# Add rsync
# Add rsync, which `bin/create-release` needs and the base image does not carry.
RUN apt-get update && apt-get --yes install rsync

# Clean up
Expand Down
10 changes: 10 additions & 0 deletions Model/BadRequestException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

namespace Leantime\Plugins\APIData\Model;

/**
* A request parameter the caller can fix. The controller turns this into a 400,
* so the message reaches the client — name the parameter and the expected shape,
* never the value that was sent.
*/
class BadRequestException extends \InvalidArgumentException {}
99 changes: 99 additions & 0 deletions Model/CoercesRequestInput.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php

namespace Leantime\Plugins\APIData\Model;

/**
* Shared coercions for the request parameter objects. Everything arrives as a
* string when the endpoints are called with query parameters, so each value has
* to be narrowed explicitly rather than cast — a plain (int) cast turns "abc"
* into 0, which silently means "from the beginning of time" for a timestamp.
*/
trait CoercesRequestInput
{
public const DEFAULT_LIMIT = 100;
public const MAX_LIMIT = 1000;

/**
* A limit below 1 is rejected rather than clamped; above the maximum it is
* capped silently, and `toArray()` echoes what was actually applied.
*/
private static function toLimit(mixed $value): int
{
$limit = self::toNonNegativeInt($value, 'limit');

if ($limit < 1) {
throw new BadRequestException('limit must be at least 1.');
}

return min($limit, self::MAX_LIMIT);
}

private static function toNonNegativeInt(mixed $value, string $name): int
{
if (!is_int($value) && !(is_string($value) && is_numeric($value))) {
throw new BadRequestException(sprintf('%s must be a whole number.', $name));
}

if ((float) $value !== (float) (int) $value) {
throw new BadRequestException(sprintf('%s must be a whole number.', $name));
}

if ((int) $value < 0) {
throw new BadRequestException(sprintf('%s cannot be negative.', $name));
}

return (int) $value;
}

private static function toTimestamp(mixed $value, string $name): ?int
{
if ($value === null || $value === '') {
return null;
}

return self::toNonNegativeInt($value, $name);
}

/**
* The README documents GET with query parameters, so a list commonly arrives
* as "1,2,3" rather than ids[]=1&ids[]=2. An empty array is kept as an empty
* list — the caller asked for a list containing nothing — while an empty
* string means the parameter was never really sent.
*
* @return list<mixed>|null
*/
private static function toList(mixed $value, string $name): ?array
{
if ($value === null) {
return null;
}

if (is_array($value)) {
$elements = [];

foreach ($value as $element) {
if (!is_scalar($element)) {
throw new BadRequestException(sprintf('%s must be a list of values.', $name));
}

// Trimmed like the comma separated form, so ?types[]=tickets%20
// is not a 400 while ?types=tickets,%20timesheets works. Only
// strings, to leave a JSON body's integers as integers.
$elements[] = is_string($element) ? trim($element) : $element;
}

return $elements;
}

if (!is_scalar($value)) {
throw new BadRequestException(sprintf('%s must be a list of values.', $name));
}

$elements = array_filter(
array_map('trim', explode(',', (string) $value)),
fn ($element) => $element !== '',
);

return [] === $elements ? null : array_values($elements);
}
}
12 changes: 9 additions & 3 deletions Model/DeletedData.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,16 @@

use Carbon\CarbonInterface;

class DeletedData
/**
* A single deletion. `deletionId` is the deleted table's own row id, which the
* caller feeds back as `start` to page on; `id` is the id of the entity that was
* deleted, and is nullable because the tracking tables allow it.
*/
readonly class DeletedData
{
public function __construct(
public int $id,
public CarbonInterface $deletedDate,
public ?int $deletionId,
public ?int $id,
public ?CarbonInterface $deletedDate,
) {}
}
Loading