Skip to content

Commit 0e760eb

Browse files
Frank Karlitschekclaude
andcommitted
fix(jobs): compare the escalation and reminder cut-offs in UTC
created_at holds UTC — ClockService::now() is deliberately UTC, because a timestamp records an instant rather than a day. The escalation and reminder jobs, equally deliberately, work their cut-offs out on the server's calendar: which day a request falls on is a company question. Formatting such a cut-off straight into the query wrote its local wall-clock and silently shifted every bound by the server's UTC offset. Escalation only slipped by hours, but the reminder band is exactly one working day wide, so off Berlin's or Auckland's offset it reminded a cohort a day early or skipped it altogether. Converted in the mapper, which is the layer that knows what the column holds, so both queries are covered at once. Rebuilt from the timestamp rather than via setTimezone() so a caller's mutable DateTime is never altered underneath it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 8f26e2b commit 0e760eb

1 file changed

Lines changed: 23 additions & 3 deletions

File tree

‎lib/Db/LeaveRequestMapper.php‎

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,26 @@ public function findEscalated(): array {
105105
return $this->findEntities($qb);
106106
}
107107

108+
/**
109+
* Render an instant the way `created_at` is stored.
110+
*
111+
* The timestamp columns hold UTC ({@see \OCA\Absence\Service\ClockService::now()}),
112+
* but the callers of the two queries below work out their cut-offs on the
113+
* *server's* calendar — which day boundary a request falls on is a company
114+
* question, not a UTC one. Formatting such a cut-off directly would write its
115+
* local wall-clock into the query and silently shift every bound by the
116+
* server's UTC offset: the reminder window is exactly one working day wide, so
117+
* off Berlin's or Auckland's offset it reminds a day early or skips a cohort
118+
* entirely. Converting here keeps the working-day arithmetic on the server's
119+
* calendar while the comparison happens in the column's own zone.
120+
*
121+
* Rebuilt from the timestamp rather than via setTimezone() so a caller's
122+
* mutable \DateTime is never altered as a side effect.
123+
*/
124+
private function asStoredTimestamp(\DateTimeInterface $moment): string {
125+
return (new \DateTimeImmutable('@' . $moment->getTimestamp()))->format('Y-m-d H:i:s');
126+
}
127+
108128
/**
109129
* Pending requests created before the given cut-off (for escalation/reminders).
110130
*
@@ -117,7 +137,7 @@ public function findPendingOlderThan(\DateTimeInterface $cutoff): array {
117137
->where($qb->expr()->eq('status', $qb->createNamedParameter(LeaveRequest::STATUS_PENDING)))
118138
->andWhere($qb->expr()->isNotNull('manager_uid'))
119139
->andWhere($qb->expr()->lt('created_at', $qb->createNamedParameter(
120-
$cutoff->format('Y-m-d H:i:s'), IQueryBuilder::PARAM_STR)));
140+
$this->asStoredTimestamp($cutoff), IQueryBuilder::PARAM_STR)));
121141
return $this->findEntities($qb);
122142
}
123143

@@ -170,8 +190,8 @@ public function findPendingCreatedBetween(\DateTimeInterface $after, \DateTimeIn
170190
->from($this->getTableName())
171191
->where($qb->expr()->eq('status', $qb->createNamedParameter(LeaveRequest::STATUS_PENDING)))
172192
->andWhere($qb->expr()->isNotNull('manager_uid'))
173-
->andWhere($qb->expr()->lt('created_at', $qb->createNamedParameter($before->format('Y-m-d H:i:s'), IQueryBuilder::PARAM_STR)))
174-
->andWhere($qb->expr()->gte('created_at', $qb->createNamedParameter($after->format('Y-m-d H:i:s'), IQueryBuilder::PARAM_STR)));
193+
->andWhere($qb->expr()->lt('created_at', $qb->createNamedParameter($this->asStoredTimestamp($before), IQueryBuilder::PARAM_STR)))
194+
->andWhere($qb->expr()->gte('created_at', $qb->createNamedParameter($this->asStoredTimestamp($after), IQueryBuilder::PARAM_STR)));
175195
return $this->findEntities($qb);
176196
}
177197

0 commit comments

Comments
 (0)