Skip to content

Commit add8511

Browse files
authored
Merge pull request #29 from nextcloud/feat/noid/hr-sick-leave-overview
Feat/noid/hr sick leave overview
2 parents 10f6a28 + edb7097 commit add8511

34 files changed

Lines changed: 1829 additions & 543 deletions
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
2+
# SPDX-License-Identifier: MIT
3+
#
4+
# App-specific workflow, deliberately not named after an organization template
5+
# (npm-test.yml et al) so the template sync in sync-workflow-templates.yml —
6+
# which copies over any file matching a template name — cannot silently replace
7+
# it. Runs the Vitest suite over the frontend helpers.
8+
9+
name: Frontend unit tests
10+
11+
on: pull_request
12+
13+
permissions:
14+
contents: read
15+
16+
concurrency:
17+
group: frontend-unit-tests-${{ github.head_ref || github.run_id }}
18+
cancel-in-progress: true
19+
20+
jobs:
21+
changes:
22+
runs-on: ubuntu-latest-low
23+
permissions:
24+
contents: read
25+
pull-requests: read
26+
27+
outputs:
28+
src: ${{ steps.changes.outputs.src}}
29+
30+
steps:
31+
- uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
32+
id: changes
33+
continue-on-error: true
34+
with:
35+
filters: |
36+
src:
37+
- '.github/workflows/**'
38+
- 'src/**'
39+
- 'package.json'
40+
- 'package-lock.json'
41+
- '**.js'
42+
- '**.vue'
43+
44+
test:
45+
runs-on: ubuntu-latest
46+
47+
needs: changes
48+
if: needs.changes.outputs.src != 'false'
49+
50+
name: Vitest
51+
steps:
52+
- name: Checkout
53+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
54+
with:
55+
persist-credentials: false
56+
57+
- name: Read package.json node and npm engines version
58+
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
59+
id: versions
60+
with:
61+
fallbackNode: '^24'
62+
fallbackNpm: '^11.3'
63+
64+
- name: Set up node ${{ steps.versions.outputs.nodeVersion }}
65+
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
66+
with:
67+
node-version: ${{ steps.versions.outputs.nodeVersion }}
68+
69+
- name: Set up npm ${{ steps.versions.outputs.npmVersion }}
70+
run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}'
71+
72+
- name: Install dependencies
73+
env:
74+
CYPRESS_INSTALL_BINARY: 0
75+
PUPPETEER_SKIP_DOWNLOAD: true
76+
run: npm ci
77+
78+
- name: Run tests
79+
run: npm run test
80+
81+
summary:
82+
permissions:
83+
contents: none
84+
runs-on: ubuntu-latest-low
85+
needs: [changes, test]
86+
87+
if: always()
88+
89+
name: frontend-unit-tests
90+
91+
steps:
92+
- name: Summary status
93+
run: if ${{ needs.changes.outputs.src != 'false' && needs.test.result != 'success' }}; then exit 1; fi

js/absence-main.mjs

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.

js/absence-main.mjs.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lib/Controller/BalanceController.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
use OCA\Absence\Service\PermissionService;
1414
use OCP\AppFramework\Controller;
1515
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
16+
use OCP\AppFramework\Http\Attribute\UserRateLimit;
1617
use OCP\AppFramework\Http\DataResponse;
1718
use OCP\IRequest;
1819

@@ -30,11 +31,13 @@ public function __construct(
3031
}
3132

3233
#[NoAdminRequired]
34+
#[UserRateLimit(limit: 60, period: 60)]
3335
public function mine(?int $year = null): DataResponse {
3436
return $this->handle(fn () => $this->balanceService->getBalance((string)$this->userId, $year));
3537
}
3638

3739
#[NoAdminRequired]
40+
#[UserRateLimit(limit: 60, period: 60)]
3841
public function forEmployee(string $uid, ?int $year = null): DataResponse {
3942
return $this->handle(function () use ($uid, $year) {
4043
if (!$this->permission->canViewBalanceOf((string)$this->userId, $uid)) {

lib/Controller/CoverageController.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use OCA\Absence\Service\CoverageService;
1212
use OCP\AppFramework\Controller;
1313
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
14+
use OCP\AppFramework\Http\Attribute\UserRateLimit;
1415
use OCP\AppFramework\Http\DataResponse;
1516
use OCP\IRequest;
1617

@@ -27,6 +28,7 @@ public function __construct(
2728
}
2829

2930
#[NoAdminRequired]
31+
#[UserRateLimit(limit: 60, period: 60)]
3032
public function index(string $from, string $to, string $scope = 'team'): DataResponse {
3133
return $this->handle(function () use ($from, $to, $scope) {
3234
$uids = $this->service->resolveScopeUids((string)$this->userId, $scope);

lib/Controller/ExportController.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use OCP\AppFramework\Controller;
1515
use OCP\AppFramework\Http;
1616
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
17+
use OCP\AppFramework\Http\Attribute\UserRateLimit;
1718
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
1819
use OCP\AppFramework\Http\DataDownloadResponse;
1920
use OCP\AppFramework\Http\DataResponse;
@@ -33,6 +34,7 @@ public function __construct(
3334

3435
#[NoAdminRequired]
3536
#[NoCSRFRequired]
37+
#[UserRateLimit(limit: 10, period: 60)]
3638
public function requests(string $from, string $to): DataResponse|DataDownloadResponse {
3739
if (!$this->permission->isHr((string)$this->userId)) {
3840
return new DataResponse(['message' => 'HR role required'], Http::STATUS_FORBIDDEN);
@@ -43,6 +45,7 @@ public function requests(string $from, string $to): DataResponse|DataDownloadRes
4345

4446
#[NoAdminRequired]
4547
#[NoCSRFRequired]
48+
#[UserRateLimit(limit: 10, period: 60)]
4649
public function balances(?int $year = null): DataResponse|DataDownloadResponse {
4750
if (!$this->permission->isHr((string)$this->userId)) {
4851
return new DataResponse(['message' => 'HR role required'], Http::STATUS_FORBIDDEN);

lib/Controller/ReportController.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
use OCA\Absence\Service\ReportService;
1414
use OCP\AppFramework\Controller;
1515
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
16+
use OCP\AppFramework\Http\Attribute\UserRateLimit;
1617
use OCP\AppFramework\Http\DataResponse;
1718
use OCP\IRequest;
1819

@@ -31,6 +32,7 @@ public function __construct(
3132
}
3233

3334
#[NoAdminRequired]
35+
#[UserRateLimit(limit: 30, period: 60)]
3436
public function balances(?int $year = null, ?string $group = null): DataResponse {
3537
return $this->handle(function () use ($year, $group) {
3638
$this->permission->assertHr((string)$this->userId);
@@ -44,6 +46,7 @@ public function balances(?int $year = null, ?string $group = null): DataResponse
4446
* never visible to line managers.
4547
*/
4648
#[NoAdminRequired]
49+
#[UserRateLimit(limit: 30, period: 60)]
4750
public function sickLeave(?int $year = null, ?string $group = null, ?int $typeId = null): DataResponse {
4851
return $this->handle(function () use ($year, $group, $typeId) {
4952
$this->permission->assertHr((string)$this->userId);
@@ -52,6 +55,7 @@ public function sickLeave(?int $year = null, ?string $group = null, ?int $typeId
5255
}
5356

5457
#[NoAdminRequired]
58+
#[UserRateLimit(limit: 30, period: 60)]
5559
public function trends(string $from, string $to): DataResponse {
5660
return $this->handle(function () use ($from, $to) {
5761
$this->permission->assertHr((string)$this->userId);

lib/Db/LeaveRequestMapper.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,31 @@ public function findAllForEmployee(string $employeeUid): array {
208208
return $this->findEntities($qb);
209209
}
210210

211+
/**
212+
* All requests (any status) for a set of employees, for computing many
213+
* balances at once. The single-employee {@see findAllForEmployee()} run in a
214+
* loop is one query per head, which an HR report over the whole company
215+
* cannot afford.
216+
*
217+
* @param string[] $employeeUids
218+
* @return array<string,LeaveRequest[]> keyed by employee uid
219+
*/
220+
public function findAllForEmployees(array $employeeUids): array {
221+
if ($employeeUids === []) {
222+
return [];
223+
}
224+
$qb = $this->db->getQueryBuilder();
225+
$qb->select('*')
226+
->from($this->getTableName())
227+
->where($qb->expr()->in('employee_uid', $qb->createNamedParameter($employeeUids, IQueryBuilder::PARAM_STR_ARRAY)))
228+
->orderBy('start_date', 'DESC');
229+
$grouped = [];
230+
foreach ($this->findEntities($qb) as $request) {
231+
$grouped[$request->getEmployeeUid()][] = $request;
232+
}
233+
return $grouped;
234+
}
235+
211236
/**
212237
* All requests overlapping a range across the whole instance (HR reporting).
213238
*

lib/Service/BalanceService.php

Lines changed: 87 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,17 @@ public function currentYear(): int {
4040
* @return array<int,array<int,array{used:float,pending:float}>> [typeId][year] => buckets
4141
*/
4242
private function computeUsage(string $employeeUid): array {
43-
$requests = $this->requestMapper->findAllForEmployee($employeeUid);
43+
return $this->usageFromRequests($this->requestMapper->findAllForEmployee($employeeUid));
44+
}
45+
46+
/**
47+
* The usage half of {@see computeUsage()}, over requests already in memory, so
48+
* a batch report can load every employee's requests in one query.
49+
*
50+
* @param LeaveRequest[] $requests
51+
* @return array<int,array<int,array{used:float,pending:float}>> [typeId][year] => buckets
52+
*/
53+
private function usageFromRequests(array $requests): array {
4454
$byId = [];
4555
foreach ($requests as $request) {
4656
$byId[$request->getId()] = $request;
@@ -84,19 +94,82 @@ private function computeUsage(string $employeeUid): array {
8494
*/
8595
public function getBalance(string $employeeUid, ?int $year = null): array {
8696
$usage = $this->computeUsage($employeeUid);
97+
$types = $this->typesById();
98+
99+
// Entitlements for this employee: restricted to the reported year when there
100+
// is one, otherwise all of them, since they also decide which years to show.
101+
$entitlements = [];
102+
foreach ($this->entitlementMapper->findForEmployee($employeeUid, $year) as $ent) {
103+
$entitlements[$ent->getYear()][$ent->getTypeId()] = $ent;
104+
}
105+
106+
return [
107+
'employeeUid' => $employeeUid,
108+
'balances' => $this->assembleRows($employeeUid, $year, $usage, $types, $entitlements),
109+
];
110+
}
111+
112+
/**
113+
* Balances for many employees in one year, in a fixed number of queries
114+
* regardless of headcount.
115+
*
116+
* The obvious loop over {@see getBalance()} costs one request query, one leave
117+
* type query and one entitlement query *per leave type* for every employee —
118+
* several thousand queries for a mid-sized company, which is what made the HR
119+
* balances report unusable at scale.
120+
*
121+
* @param string[] $employeeUids
122+
* @return array<string,list<array<string,mixed>>> balance rows keyed by employee uid
123+
*/
124+
public function getBalancesForEmployees(array $employeeUids, int $year): array {
125+
if ($employeeUids === []) {
126+
return [];
127+
}
128+
$types = $this->typesById();
129+
$requestsByEmployee = $this->requestMapper->findAllForEmployees($employeeUids);
130+
131+
$entitlementsByEmployee = [];
132+
foreach ($this->entitlementMapper->findForYear($year) as $ent) {
133+
$entitlementsByEmployee[$ent->getEmployeeUid()][$year][$ent->getTypeId()] = $ent;
134+
}
135+
136+
$result = [];
137+
foreach ($employeeUids as $uid) {
138+
$usage = $this->usageFromRequests($requestsByEmployee[$uid] ?? []);
139+
$result[$uid] = $this->assembleRows($uid, $year, $usage, $types, $entitlementsByEmployee[$uid] ?? []);
140+
}
141+
return $result;
142+
}
143+
144+
/**
145+
* @return array<int,LeaveType>
146+
*/
147+
private function typesById(): array {
87148
$types = [];
88149
foreach ($this->leaveTypeMapper->findAll() as $type) {
89150
$types[$type->getId()] = $type;
90151
}
152+
return $types;
153+
}
91154

155+
/**
156+
* Turn precomputed usage, types and entitlements into balance rows. Shared by
157+
* the single-employee and batch paths so both produce identical output.
158+
*
159+
* @param array<int,array<int,array{used:float,pending:float}>> $usage
160+
* @param array<int,LeaveType> $types
161+
* @param array<int,array<int,Entitlement>> $entitlements [year][typeId]
162+
* @return list<array<string,mixed>>
163+
*/
164+
private function assembleRows(string $employeeUid, ?int $year, array $usage, array $types, array $entitlements): array {
92165
// Determine which years to report.
93166
$years = [];
94167
if ($year !== null) {
95168
$years[$year] = true;
96169
} else {
97170
$years[$this->currentYear()] = true;
98-
foreach ($this->entitlementMapper->findForEmployee($employeeUid) as $ent) {
99-
$years[$ent->getYear()] = true;
171+
foreach (array_keys($entitlements) as $entYear) {
172+
$years[$entYear] = true;
100173
}
101174
foreach ($usage as $perYear) {
102175
foreach (array_keys($perYear) as $y) {
@@ -114,38 +187,39 @@ public function getBalance(string $employeeUid, ?int $year = null): array {
114187
if (!$type->getCountsAgainstBalance() && $used === 0.0 && $pending === 0.0) {
115188
continue;
116189
}
117-
$rows[] = $this->buildRow($employeeUid, $reportYear, $type, $used, $pending);
190+
$rows[] = $this->buildRow($employeeUid, $reportYear, $type, $used, $pending, $entitlements[$reportYear][$typeId] ?? null);
118191
}
119192
}
120193
// Newest year first, then sort_order.
121194
usort($rows, static function (array $a, array $b): int {
122195
return [$b['year'], $a['sortOrder']] <=> [$a['year'], $b['sortOrder']];
123196
});
124-
125-
return [
126-
'employeeUid' => $employeeUid,
127-
'balances' => $rows,
128-
];
197+
return $rows;
129198
}
130199

131200
/**
132201
* @return array<string,mixed>
133202
*/
134-
private function buildRow(string $employeeUid, int $year, LeaveType $type, float $used, float $pending): array {
203+
/**
204+
* @param ?Entitlement $ent the stored entitlement, or null when none exists.
205+
* Passed in rather than looked up here so a batch
206+
* report can resolve every employee's rows from one
207+
* preloaded index instead of a query per row.
208+
*/
209+
private function buildRow(string $employeeUid, int $year, LeaveType $type, float $used, float $pending, ?Entitlement $ent): array {
135210
$entitlement = null;
136211
$base = 0.0;
137212
$carry = 0.0;
138213
$adjust = 0.0;
139214
$entitlementId = null;
140215
if ($type->getCountsAgainstBalance()) {
141-
try {
142-
$ent = $this->entitlementMapper->findFor($employeeUid, $year, $type->getId());
216+
if ($ent !== null) {
143217
$base = $ent->getBaseDays();
144218
$carry = $ent->getCarryOverDays();
145219
$adjust = $ent->getManualAdjustment();
146220
$entitlement = $ent->getEntitlement();
147221
$entitlementId = $ent->getId();
148-
} catch (DoesNotExistException) {
222+
} else {
149223
// No row yet: only the primary annual type inherits the configured
150224
// default allotment; other counting types start at zero until HR
151225
// grants an entitlement (avoids fabricating balances, §6.1).

lib/Service/ClockService.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,20 @@ public function serverYear(): int {
6060
return (int)$this->serverNow()->format('Y');
6161
}
6262

63+
/**
64+
* The current instant for a stored timestamp column (created_at, decided_at …).
65+
*
66+
* Deliberately UTC and not one of the two day-boundary methods above: a
67+
* timestamp records *when* something happened, which is the same moment for
68+
* everyone, so there is no user or server timezone to pick. It exists only so
69+
* the workflow's timestamps come from the same pinnable clock as everything
70+
* else — `new \DateTime()` cannot be frozen in a test. Mutable because the
71+
* entity setters take \DateTime.
72+
*/
73+
public function now(): \DateTime {
74+
return \DateTime::createFromImmutable($this->at(new \DateTimeZone('UTC')));
75+
}
76+
6377
public function userNow(): \DateTimeImmutable {
6478
return $this->at($this->dateTimeZone->getTimeZone());
6579
}

0 commit comments

Comments
 (0)