Keep timesheets modified current via insert/update triggers - #14
Keep timesheets modified current via insert/update triggers#14turegjorup wants to merge 3 commits into
Conversation
Add BEFORE INSERT/UPDATE triggers on zp_timesheets that set modified to NOW(), installed alongside the deleted-tracking triggers. Some core write paths (the ON DUPLICATE KEY UPDATE branches of addTime, upsertTimesheetEntry and punchOut) leave modified stale, so those changes were missed by modifiedAfter sync. The triggers cover every write path.
Plain CREATE TRIGGER errors if the trigger already exists, so re-running install() (or re-installing after a partial failure) would fail even though the tables use CREATE TABLE IF NOT EXISTS. Use MariaDB's CREATE OR REPLACE TRIGGER so install always (re)installs the current definition, and use IF EXISTS on the uninstall drops.
There was a problem hiding this comment.
We may need the same modified triggers for projects and tickets.
AI Review
Review — PR #14: Keep timesheets modified current via insert/update triggers
Branch: feature/timesheets-modified-trigger → develop
Files changed: CHANGELOG.md, README.md, Services/APIData.php (+29/−6)
Verdict: Correct fix for a real bug. Two changes requested, one item to verify on production.
Summary
The diagnosis is right. upsertTimesheetEntry()
(~/qork/leantime/app/Domain/Timesheets/Repositories/Timesheets.php:736-754) calls Laravel
upsert() with update columns ['hours'], so on duplicate key modified is never written.
Those rows go invisible to the modifiedAfter filter in
Repositories/ApiDataRepository.php:73 and the sync silently misses them. Triggers on
zp_timesheets are the right way to close it.
1. CREATE OR REPLACE TRIGGER locks us to MariaDB — request change
Services/APIData.php:55, 63, 71, 79, 86
This works in our environment (MariaDB has supported it since 10.1.4), so it isn't broken
today. But MySQL has no OR REPLACE for triggers at all — the 8.4 grammar is
CREATE [DEFINER = user] TRIGGER [IF NOT EXISTS] trigger_name …. On MySQL, install() aborts
at the first of these statements, which means not only do the two new triggers not get
created, the three pre-existing itk_*_deleted_trigger definitions stop being created too.
Leantime itself ships mysql:8.4 in all three of its compose files, so we shouldn't take the
dependency.
Use DROP TRIGGER IF EXISTS + plain CREATE TRIGGER. Valid on MySQL 5.0→8.4 and every
MariaDB, and more idempotent than CREATE TRIGGER IF NOT EXISTS — that variant silently
keeps an outdated trigger body rather than redefining it, which defeats the purpose of making
install() re-runnable.
DROP TRIGGER IF EXISTS itk_projects_deleted_trigger;
CREATE TRIGGER itk_projects_deleted_trigger
AFTER DELETE ON zp_projects
FOR EACH ROW
BEGIN
INSERT INTO itk_projects_deleted(entryId)
VALUES (OLD.id);
END;…for all five triggers. The two new ones keep the bodies already in the PR:
DROP TRIGGER IF EXISTS itk_timesheets_modified_update_trigger;
CREATE TRIGGER itk_timesheets_modified_update_trigger
BEFORE UPDATE ON zp_timesheets
FOR EACH ROW
BEGIN
SET NEW.modified = NOW();
END;No DELIMITER needed — that's a mysql CLI feature; both servers parse BEGIN … END;
correctly over the wire, as the existing code already relies on.
- Errors after the first statement are silently swallowed — request change
Services/APIData.php:97-102 and 123-128
install() sends one multi-statement batch through PDO and calls execute() once. PDO
reports only the first statement's outcome; failures in later statements surface on
nextRowset(), which is never called. So a fresh install can create the three tables, fail
on trigger #1, and report success.
This is pre-existing, but it's exactly how issue #1 would have reached production looking
like a clean install — and it will swallow the next problem too (a missing TRIGGER grant,
say). Both methods duplicate the same PDO block, so extract one helper:
/**
* Runs a multi-statement SQL batch.
*
* PDO surfaces only the first statement's outcome, so the remaining result sets have to
* be drained for a failure in any later statement to be reported at all.
*/
private function executeMultiStatement(string $sql): void
{
// Laravel's statement() does not handle multi-statement SQL, so go through PDO.
$pdo = app('db')->connection()->getPdo();
$stmn = $pdo->prepare($sql);
$stmn->execute();
do {
// Errors in statements after the first are raised here, not by execute().
} while ($stmn->nextRowset());
$stmn->closeCursor();
}- Verify NOW() matches Leantime's clock on production — review item
The trigger's NOW() uses the database session time_zone. Leantime writes the same
column with PHP date('Y-m-d H:i:s') under app.timezone
(~/qork/leantime/app/Core/Bootstrap/LoadConfig.php:106; note configuration.sample.php:36
ships America/Los_Angeles). The API then reads every value back as UTC regardless
(Services/APIData.php:228).
If those two clocks differ, modified ends up holding mixed-timezone values, and a
trigger-written row can land behind a PHP-written one — which reopens the exact miss this PR
is closing, just with a smaller window. Same on MariaDB and MySQL.
Before merge, confirm on production:
SELECT @@session.time_zone, @@global.time_zone, NOW(), UTC_TIMESTAMP();
Compare against the configured Leantime timezone. They're aligned in the default Docker setup,
so this is likely fine — but it's cheap to check and expensive to debug later. If they differ,
align the DB session timezone rather than papering over it in the trigger body. Worth noting
the pre-existing dateDeleted datetime DEFAULT NOW() on the itk_*_deleted tables
(Services/APIData.php:36, 44, 51) has the same exposure, so a mismatch is worth fixing
centrally.
- Existing rows are not backfilled — handled operationally
zp_timesheets.modified is datetime DEFAULT NULL
(~/qork/leantime/app/Domain/Install/Repositories/Install.php:674), and NULL never satisfies
the modifiedAfter comparison — so those rows stay invisible to incremental sync
indefinitely, not just until the next write. Agreed to handle this with one full resync
(no modifiedAfter) after deploy rather than backfill SQL. Worth a line in the README so the
next person deploying this knows.
Accepted as-is
- Unconditional SET NEW.modified = NOW() on UPDATE. saveWeeklyTimesheetEntries()
(~/qork/leantime/app/Domain/Timesheets/Services/Timesheets.php:400-463) re-upserts every
cell of the weekly grid whether it changed or not, and both engines fire BEFORE UPDATE on the
ON DUPLICATE KEY UPDATE branch — so one grid save bumps modified across that week and the
next sync re-pulls it. Churn, not corruption, and over-syncing beats the under-syncing we
have now. Deliberate tradeoff, noted here so it isn't rediscovered as a bug later. - DROP TRIGGER IF EXISTS in uninstall() (lines 108-112) is a real fix — the old bare
DROP TRIGGER aborted on the first already-missing trigger. - The BEFORE INSERT trigger is worth keeping even though the current insert path already
sets modified: it guarantees no other writer can leave the column NULL, and NULL is
permanently invisible to incremental sync. - Changelog matches house format and satisfies the CI check (.github/workflows/pr.yml).
Minor
The PR checklist ticks "covered by test cases" / "passes our tests", but the repo has no test
suite and CI only checks the changelog. Not blocking — the template asks for a comment
explaining the exemption instead of ticked boxes.
Verification before merge
Run against both engines: MariaDB (production) and MySQL 8.4 (what Leantime ships, in
~/qork/leantime/.docker/docker-compose.yml). The MySQL run is the whole point of change #1
and is what the PR currently fails.
- Fresh install() → SHOW TRIGGERS FROM ; lists all five triggers, on both engines.
- Run install() again → succeeds, still exactly five triggers.
- Insert a timesheet row → modified is set, not NULL.
- UPDATE zp_timesheets SET hours = hours + 1 WHERE id = ?; → modified advances.
- The actual bug path: save the weekly timesheet grid so upsertTimesheetEntry() takes
its duplicate-key branch, and confirm modified advances. Before this PR it does not. - POST /apidata/api/timesheets with modifiedAfter set just before step 5 → row is returned.
- uninstall() twice → no error on the second run; the three itk_*_deleted tables and their
data survive. - Error-drain check: temporarily append an invalid statement to the install() batch and
confirm it now throws instead of passing silently. - Timezone check from item 3 on production.
|
Superseded by #20 |
Link to ticket
https://leantime.itkdev.dk/#/tickets/showTicket/7893
Description
Timesheet changes were not all picked up by
modifiedAftersync because some core write paths leavezp_timesheets.modifiedstale — specifically theON DUPLICATE KEY UPDATEbranches ofaddTime,upsertTimesheetEntryandpunchOut, which accumulate hours without updatingmodified.This adds two triggers on
zp_timesheets, installed alongside the existing deleted-tracking triggers:itk_timesheets_modified_insert_trigger(BEFORE INSERT) setsmodified = NOW().itk_timesheets_modified_update_trigger(BEFORE UPDATE) setsmodified = NOW().Both are dropped on uninstall. This guarantees
modifiedreflects the last change regardless of which write path made it.All triggers are now installed with
CREATE OR REPLACE TRIGGER(MariaDB) soinstall()is idempotent and always (re)installs the current definition — the previous plainCREATE TRIGGERwould error on a re-run even though the tables useCREATE TABLE IF NOT EXISTS. The uninstall drops useIF EXISTS. This applies to the existing deleted-tracking triggers too.Note: existing rows with stale/zero
modifiedvalues are not backfilled; they update on their next write (or via a one-timeUPDATE).Steps to reproduce
(userId, ticketId, workDate, kind)unique key). Hours accumulate into the existing row viaON DUPLICATE KEY UPDATE.hourshas increased butmodifiedis unchanged (still the original value).modifiedAfterset after the originalmodified— the updated entry is not returned, so the hours change is missed.With the triggers installed, step 3 shows
modifiedadvancing toNOW()and step 4 returns the entry.Checklist