Skip to content

Commit 6debcae

Browse files
authored
Merge pull request #20 from ITK-Leantime/feature/8000-modified-column
feat: own the sync watermark in an itk_data_api_modified column
2 parents d226c3a + 42d5750 commit 6debcae

10 files changed

Lines changed: 830 additions & 107 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22

33
## [Unreleased]
44

5+
* [PR-20](https://github.com/ITK-Leantime/data-api/pull/20)
6+
* 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.
7+
* 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.
8+
* Added `modified` to the users endpoint.
9+
* Changed the deletion triggers to stamp `dateDeleted` in UTC, so `deleted` filters against the same clock the responses are read in.
10+
* 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.
11+
* 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.
512
* [PR-19](https://github.com/ITK-Leantime/data-api/pull/19)
613
* Validated request parameters, so malformed input answers 400 with a reason instead of failing with a 500.
714
* Rejected a limit below 1, which previously dropped the LIMIT clause and returned every row, and capped limit at 1000.
@@ -10,7 +17,6 @@
1017
* Fixed an empty projectIds list dropping the filter, which answered with every row instead of none.
1118
* Trimmed whitespace around ids, projectIds and types elements sent in array form.
1219
* Renamed InvalidRequestException to BadRequestException, matching the 400 it turns into.
13-
1420
* [PR-18](https://github.com/ITK-Leantime/data-api/pull/18)
1521
* Allowed null values in API models, so entries referencing deleted users or deleted tickets no longer fail the whole request.
1622
* Added userId to timesheets, so hours logged by a deleted user stay attributable.

Model/WorkerData.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,16 @@
22

33
namespace Leantime\Plugins\APIData\Model;
44

5+
use Carbon\CarbonInterface;
6+
57
readonly class WorkerData
68
{
79
public function __construct(
810
public int $id,
911
public ?string $email,
12+
// getWorkers() maps an all-blank name to null rather than to a string
13+
// of whitespace, so a user with no name at all has none here.
1014
public ?string $name,
15+
public ?CarbonInterface $modified,
1116
) {}
1217
}

README.md

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,37 @@ An API plugin for exposing data to external applications.
44

55
Copy the plugin to the folder app/Plugins/APIData, install and enable.
66

7-
During installation the following tables will be created to tracked deleted entities:
7+
## What installation changes in the database
8+
9+
The following tables are created to track deleted entities:
810

911
* itk_projects_deleted
1012
* itk_tickets_deleted
1113
* itk_timesheets_deleted
1214

13-
3 triggers will also be installed that populate the tables when entities are deleted.
15+
3 triggers populate those tables when entities are deleted.
16+
17+
An `itk_data_api_modified` column, with an index, is added to `zp_projects`, `zp_tickets`,
18+
`zp_timesheets` and `zp_user`, and 8 more triggers (insert and update, one pair per table) keep it
19+
current. This column exists because Leantime does not maintain its own `modified` column on every write
20+
path — time logged from the weekly grid, for instance, leaves it untouched. Since the triggers sit in the
21+
database, no write path can bypass them.
22+
23+
The column is written as UTC, and `modifiedAfter` filters on it.
24+
25+
The Leantime database user needs `ALTER` on `zp_projects`, `zp_tickets`, `zp_timesheets` and
26+
`zp_user`, on top of the `CREATE` and `TRIGGER` the plugin already needed. Installation fails, and
27+
says so, if the grant is missing.
28+
29+
NB! Install and update the plugin with the site down. The triggers are absent while the plugin is
30+
being replaced, and an edit made in that window is not recoverable — installing only stamps rows that
31+
have no timestamp at all, which covers new rows and nothing else.
32+
33+
NB! Installing stamps every existing row with the install time, so **the first sync after installing
34+
returns everything once**.
1435

15-
NB! The triggers are removed on uninstall, but the tables are left alone to avoid data loss through install/uninstalls.
36+
NB! All 11 triggers are removed on uninstall, but the tables, the column and its data are left alone to
37+
avoid data loss through install/uninstalls.
1638

1739
## Endpoints
1840

@@ -25,14 +47,15 @@ The API consists of the following endpoints:
2547

2648
GET/POST: `https://{{YOUR_DOMAIN}}/apidata/api/{{TYPE}}`
2749

28-
TYPE: projects, milestones, tickets, timesheets
50+
TYPE: projects, milestones, tickets, timesheets, users
2951

3052
Attach query/body parameters to the request:
3153

3254
* start: Starting id of the results.
3355
* limit: Maximum number of results to get from start id in ascending order. Must be at least 1,
3456
and is capped at 1000. The limit that was actually applied is echoed in `parameters`.
3557
* modifiedAfter: Only retrieve entries that have a modified later than modifiedAfter (unix timestamp).
58+
All five types, users included, carry a `modified` timestamp in the response.
3659
* ids: Array of ids to retrieve. A comma separated string is also accepted, e.g. `?ids=1,2,3`.
3760
* projectIds: Array of projectIds. Limits the entities to those attached to projects in projectIds.
3861
Only applies for types: milestone, tickets, timesheets.

Repositories/ApiDataRepository.php

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,10 @@ private function query(): Builder
1717
public function getProjects(int $startId, int $limit, ?int $modifiedAfter = null, ?array $ids = null): array
1818
{
1919
return $this->query()
20-
->select(["id", "name", "modified"])
20+
->select(["project.id", "project.name", $this->modifiedSelect("project")])
2121
->from("zp_projects", "project")
2222
->where("project.id", ">=", $startId)
23-
->when($modifiedAfter !== null, fn ($query) => $query->where("project.modified", ">=", CarbonImmutable::createFromTimestamp($modifiedAfter)->format(APIData::DATE_FORMAT)))
23+
->when($modifiedAfter !== null, fn ($query) => $query->where($this->modified("project"), ">=", $this->cutoff($modifiedAfter)))
2424
->when($ids !== null, fn ($query) => $query->whereIn("project.id", $ids))
2525
->orderBy("id", "ASC")
2626
->limit($limit)
@@ -31,11 +31,11 @@ public function getProjects(int $startId, int $limit, ?int $modifiedAfter = null
3131
public function getMilestones(int $startId, int $limit, ?int $modifiedAfter = null, ?array $ids = null, ?array $projectIds = null): array
3232
{
3333
return $this->query()
34-
->select(["id", "headline", "projectId", "modified"])
34+
->select(["ticket.id", "ticket.headline", "ticket.projectId", $this->modifiedSelect("ticket")])
3535
->from("zp_tickets", "ticket")
3636
->where("ticket.id", ">=", $startId)
3737
->where("ticket.type", "=", "milestone")
38-
->when($modifiedAfter !== null, fn ($query) => $query->where("ticket.date", ">=", CarbonImmutable::createFromTimestamp($modifiedAfter)->format(APIData::DATE_FORMAT)))
38+
->when($modifiedAfter !== null, fn ($query) => $query->where($this->modified("ticket"), ">=", $this->cutoff($modifiedAfter)))
3939
->when($ids !== null, fn ($query) => $query->whereIn("ticket.id", $ids))
4040
->when($projectIds !== null, fn ($query) => $query->whereIn("ticket.projectId", $projectIds))
4141
->orderBy("id", "ASC")
@@ -47,12 +47,12 @@ public function getMilestones(int $startId, int $limit, ?int $modifiedAfter = nu
4747
public function getTickets(int $startId, int $limit, ?int $modifiedAfter = null, ?array $ids = null, ?array $projectIds = null): array
4848
{
4949
return $this->query()
50-
->select(["ticket.id", "ticket.headline", "ticket.projectId", "ticket.status", "ticket.planHours", "ticket.hourRemaining", "ticket.tags", "ticket.dateToFinish", "ticket.editTo", "ticket.milestoneid", "ticket.modified", "user.username"])
50+
->select(["ticket.id", "ticket.headline", "ticket.projectId", "ticket.status", "ticket.planHours", "ticket.hourRemaining", "ticket.tags", "ticket.dateToFinish", "ticket.editTo", "ticket.milestoneid", $this->modifiedSelect("ticket"), "user.username"])
5151
->from("zp_tickets", "ticket")
5252
->where("ticket.id", ">=", $startId)
5353
->where("ticket.type", "<>", "milestone")
5454
->leftJoin('zp_user as user', "user.id", "=", "ticket.editorId")
55-
->when($modifiedAfter !== null, fn ($query) => $query->where("ticket.date", ">=", CarbonImmutable::createFromTimestamp($modifiedAfter)->format(APIData::DATE_FORMAT)))
55+
->when($modifiedAfter !== null, fn ($query) => $query->where($this->modified("ticket"), ">=", $this->cutoff($modifiedAfter)))
5656
->when($ids !== null, fn ($query) => $query->whereIn("ticket.id", $ids))
5757
->when($projectIds !== null, fn ($query) => $query->whereIn("ticket.projectId", $projectIds))
5858
->orderBy("id", "ASC")
@@ -65,12 +65,12 @@ public function getTimesheets(int $startId, int $limit, ?int $modifiedAfter = nu
6565
{
6666
return $this->query()
6767
->from("zp_timesheets", "timesheet")
68-
->select(["timesheet.id", "timesheet.description", "timesheet.hours", "timesheet.workDate", "timesheet.modified", "timesheet.ticketId", "timesheet.userId", "timesheet.kind", "user.username", "ticket.projectId"])
68+
->select(["timesheet.id", "timesheet.description", "timesheet.hours", "timesheet.workDate", $this->modifiedSelect("timesheet"), "timesheet.ticketId", "timesheet.userId", "timesheet.kind", "user.username", "ticket.projectId"])
6969
->where("timesheet.id", ">=", $startId)
7070
->whereNotNull("timesheet.hours")
7171
->leftJoin('zp_user as user', "user.id", "=", "timesheet.userId")
7272
->leftJoin('zp_tickets as ticket', "ticket.id", "=", "timesheet.ticketId")
73-
->when($modifiedAfter !== null, fn ($query) => $query->where("timesheet.modified", ">=", CarbonImmutable::createFromTimestamp($modifiedAfter)->format(APIData::DATE_FORMAT)))
73+
->when($modifiedAfter !== null, fn ($query) => $query->where($this->modified("timesheet"), ">=", $this->cutoff($modifiedAfter)))
7474
->when($ids !== null, fn ($query) => $query->whereIn("timesheet.id", $ids))
7575
->when($projectIds !== null, fn ($query) => $query->whereIn("ticket.projectId", $projectIds))
7676
->orderBy("timesheet.id", "ASC")
@@ -86,10 +86,10 @@ public function getWorkers(int $startId, int $limit, ?int $modifiedAfter = null,
8686
// CONCAT_WS skips a missing name part, so a worker with only a
8787
// firstname keeps a usable name. NULLIF turns an all-blank name into
8888
// null rather than a string of whitespace.
89-
->select(["worker.id", "worker.username", DB::raw("NULLIF(TRIM(CONCAT_WS(' ', worker.firstname, worker.lastname)), '') as name")])
89+
->select(["worker.id", "worker.username", DB::raw("NULLIF(TRIM(CONCAT_WS(' ', worker.firstname, worker.lastname)), '') as name"), $this->modifiedSelect("worker")])
9090
->where("worker.id", ">=", $startId)
9191
->where("worker.source", "<>", "api")
92-
->when($modifiedAfter !== null, fn ($query) => $query->where("worker.modified", ">=", CarbonImmutable::createFromTimestamp($modifiedAfter)->format(APIData::DATE_FORMAT)))
92+
->when($modifiedAfter !== null, fn ($query) => $query->where($this->modified("worker"), ">=", $this->cutoff($modifiedAfter)))
9393
->when($ids !== null, fn ($query) => $query->whereIn("worker.id", $ids))
9494
->orderBy("worker.id", "ASC")
9595
->limit($limit)
@@ -111,8 +111,34 @@ public function getDeleted(string $type, ?int $deletedAfter = null): array
111111
->select(["entryId", "dateDeleted"])
112112
->when($type === APIData::TYPE_MILESTONES, fn ($query) => $query->where('type', '=', 'milestone'))
113113
->when($type === APIData::TYPE_TICKETS, fn ($query) => $query->where('type', '<>', 'milestone'))
114-
->when($deletedAfter !== null, fn ($query) => $query->where("entry.dateDeleted", ">=", CarbonImmutable::createFromTimestamp($deletedAfter)->format(APIData::DATE_FORMAT)))
114+
->when($deletedAfter !== null, fn ($query) => $query->where("entry.dateDeleted", ">=", $this->cutoff($deletedAfter)))
115115
->get()
116116
->toArray();
117117
}
118+
119+
/**
120+
* The plugin-owned timestamp column, qualified by the query's table alias.
121+
* Core's own `modified` is not maintained on every write path, so it cannot
122+
* carry the modifiedAfter contract — see SchemaRepository.
123+
*/
124+
private function modified(string $alias): string
125+
{
126+
return sprintf('%s.%s', $alias, SchemaRepository::COLUMN);
127+
}
128+
129+
/**
130+
* Exposed to consumers as plain `modified`, so the column swap is invisible
131+
* to them and to the mapping in APIData.
132+
*/
133+
private function modifiedSelect(string $alias): string
134+
{
135+
return sprintf('%s as modified', $this->modified($alias));
136+
}
137+
138+
private function cutoff(int $timestamp): string
139+
{
140+
// Explicit UTC: Carbon 3 defaults to it, but Carbon comes from the host
141+
// Leantime install, and the triggers write UTC_TIMESTAMP().
142+
return CarbonImmutable::createFromTimestamp($timestamp, 'UTC')->format(APIData::DATE_FORMAT);
143+
}
118144
}

0 commit comments

Comments
 (0)