Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
41 changes: 40 additions & 1 deletion SPECIFICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ There are four effective roles. A single user may hold several simultaneously

| Role | How assigned | Capabilities |
|------|-------------|--------------|
| **Employee** | Every logged-in user | Create/edit/cancel own requests, view own balance, view own history, see team who's-off calendar. |
| **Employee** | Every logged-in user **except guest accounts** (§2.2) | Create/edit/cancel own requests, view own balance, view own history, see team who's-off calendar. |
| **Line manager** | Derived from the LDAP `manager` attribute (see §2.1) — a user is a manager of everyone whose `manager` attribute points to them | Approve/reject/comment on direct reports' requests, view direct reports' calendars and balances, receive coverage-conflict warnings. |
| **HR** | Membership of a configurable Nextcloud group (default group id `hr`, set in admin settings) | Company-wide overview, statistics, exports, manage entitlements, manage public-holiday calendar, override any decision, act on escalated requests, edit/adjust any request and balance. |
| **App admin** | Nextcloud server admins | Configure app settings (§11): HR group, leave types, escalation window, default entitlements, CalDAV target. |
Expand All @@ -72,6 +72,41 @@ There are four effective roles. A single user may hold several simultaneously
- **No manager found:** the request is created with `manager_uid = NULL` and is
routed directly to HR (treated as immediately escalated — see §5.4).

### 2.2 Who counts as an employee (guest accounts)

Not every account on an instance is a member of staff. **Guest accounts — users
created by the [Guests app](https://github.com/nextcloud/guests) — are external
people invited to collaborate on files. They have no entitlement and take no
leave, so the app does not treat them as employees.**

Without this rule every guest would sit in the balances report and the who's-off
calendar forever, with an empty allowance and nothing to show.

- **One definition, one place.** `EmployeeDirectory` is the only component that
enumerates users; `ReportService`, `EntitlementService`, `CoverageService` and
`ManagerResolver` all ask it rather than walking `IUserManager` themselves. A
rule stated in four copies is a rule that holds in three.
- **Detection.** A guest is a user in the Guests app's own user backend, i.e.
`IUser::getBackendClassName() === 'Guests'` — the same thing
`OCA\Guests\GuestManager::isGuest()` checks. Read this way the app needs **no
dependency on the Guests app**: where it is absent or disabled, no account has
that backend and the rule is simply never true.
- **Consequences.** Guests do not appear in balances, statistics, the sick-leave
overview, exports, the who's-off calendar, the HR absence list or any people
picker; they are nobody's direct report or peer, and cannot be resolved as a
line manager (a request routed to one could never be approved).
- **Enforced, not just hidden.** The API rejects creating leave for a guest —
including by HR, who may otherwise record for anyone — nominating a guest as a
replacement, and setting a guest's entitlement. Filtering only the UI would
leave the rule one crafted request away from being bypassed.
- **Pickers.** The people pickers call the app's own
`GET /api/employees/search` rather than core's autocomplete, because only the
server can tell a guest from a colleague. That endpoint wraps the same
collaborator search, so the admin's user-enumeration settings still apply
exactly as elsewhere; guests are removed from what it returns.
- Existing records for someone who later becomes a guest are left untouched in
the database — they simply stop being listed.

---

## 3. Core Concepts & Data Model
Expand Down Expand Up @@ -738,6 +773,10 @@ NcContent(app-name="absence")
type, status and year, paged with a "Load more" button. Rows are the same
`RequestListItem` as elsewhere and open the detail sidebar, whose **Edit** and
**Cancel** controls are what let HR correct a wrong vacation or sick day (§5.6).
People are named, never printed as user ids: requests are serialized with an
`employeeName` (display name, falling back to the uid for a deleted account),
and the sidebar names the employee under its title whenever the leave is not
the viewer's own.
Accepts `?employee=&employeeName=&type=&status=&year=` so other views can deep-link
into it — the *Sick leave* overview does, from each employee row.
- **HR** (HR group only): *Balances* (searchable/sortable data table →
Expand Down
3 changes: 3 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
['name' => 'request#reject', 'url' => '/api/requests/{id}/reject', 'verb' => 'POST'],
['name' => 'request#addComment', 'url' => '/api/requests/{id}/comments', 'verb' => 'POST'],

// People (autocomplete for the employee / replacement pickers)
['name' => 'employee#search', 'url' => '/api/employees/search', 'verb' => 'GET'],

// Balances & entitlements
['name' => 'balance#mine', 'url' => '/api/balance', 'verb' => 'GET'],
['name' => 'balance#forEmployee', 'url' => '/api/employees/{uid}/balance', 'verb' => 'GET'],
Expand Down

Large diffs are not rendered by default.

Large diffs are not rendered by default.

14 changes: 7 additions & 7 deletions js/absence-main.mjs

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion js/absence-main.mjs.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion js/absence-personal-settings.mjs

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions js/index-C0SbjTRS.chunk.mjs → js/index-D9p6E40W.chunk.mjs

Large diffs are not rendered by default.

Large diffs are not rendered by default.

85 changes: 85 additions & 0 deletions lib/Controller/EmployeeController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Absence\Controller;

use OCA\Absence\Service\EmployeeDirectory;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\UserRateLimit;
use OCP\AppFramework\Http\DataResponse;
use OCP\Collaboration\Collaborators\ISearch;
use OCP\IRequest;
use OCP\Share\IShare;

class EmployeeController extends Controller {
use ApiControllerTrait;

public function __construct(
string $appName,
IRequest $request,
private ISearch $collaboratorSearch,
private EmployeeDirectory $employees,
) {
parent::__construct($appName, $request);
}

/**
* Employee autocomplete for the app's people pickers (record-on-behalf,
* replacement, the HR absence filter).
*
* This exists instead of calling core's `core/autocomplete/get` from the
* client because "who is an employee" is a server-side rule: the client
* cannot tell a guest account from a colleague, so a client-side filter
* would be no filter at all (§2.2).
*
* Results still come from the collaborator search, so the admin's user
* enumeration settings continue to apply exactly as they do everywhere else
* — with enumeration restricted, that search returns nothing and so does
* this. Guests are then removed from whatever it did return.
*/
#[NoAdminRequired]
#[UserRateLimit(limit: 60, period: 60)]
public function search(string $search = '', int $limit = 20): DataResponse {
return $this->handle(function () use ($search, $limit): array {
$search = trim($search);
if ($search === '') {
return [];
}
// Over-fetch a little: guests removed below would otherwise eat into the
// requested number of suggestions and shorten the list for no reason.
$limit = max(1, min($limit, 50));
[$results] = $this->collaboratorSearch->search($search, [IShare::TYPE_USER], false, $limit * 2, 0);

$candidates = array_merge(
$results['exact']['users'] ?? [],
$results['users'] ?? [],
);

$employees = [];
foreach ($candidates as $candidate) {
$uid = (string)($candidate['value']['shareWith'] ?? '');
// Exact and wide matches overlap, so the same person can appear twice.
if ($uid === '' || isset($employees[$uid])) {
continue;
}
if (!$this->employees->isEmployee($uid)) {
continue;
}
$employees[$uid] = [
'uid' => $uid,
'displayName' => (string)($candidate['label'] ?? $uid),
];
if (count($employees) >= $limit) {
break;
}
}
return array_values($employees);
});
}
}
7 changes: 2 additions & 5 deletions lib/Service/CoverageService.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public function __construct(
private ManagerResolver $managerResolver,
private PermissionService $permission,
private ConfigService $config,
private EmployeeDirectory $employees,
private IUserManager $userManager,
) {
}
Expand All @@ -37,11 +38,7 @@ public function __construct(
*/
public function resolveScopeUids(string $actorUid, string $scope): array {
if ($scope === self::SCOPE_COMPANY && $this->permission->isHr($actorUid)) {
$uids = [];
$this->userManager->callForAllUsers(static function ($user) use (&$uids): void {
$uids[] = $user->getUID();
});
return $uids;
return $this->employees->listAll();
}
// Team scope: the actor's reports if they manage, otherwise their peers + self.
$reports = $this->managerResolver->getDirectReports($actorUid);
Expand Down
130 changes: 130 additions & 0 deletions lib/Service/EmployeeDirectory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Absence\Service;

use OCP\IGroupManager;
use OCP\IUser;
use OCP\IUserManager;

/**
* Who counts as an employee (§2.2).
*
* Every part of the app that needs "the people this app is about" asks here,
* rather than enumerating `IUserManager` itself. Previously four services each
* had their own copy of that loop, so any rule about who is *not* an employee
* had to be repeated four times to hold — and would silently not hold wherever
* it was forgotten.
*
* The one rule today is that **guest accounts are not employees**: they are
* external people invited to share files, they have no entitlement, and they do
* not take leave. Listing them would put every guest in the balances report and
* the who's-off calendar with an empty allowance forever.
*
* A guest is a user in the Guests app's own user backend, which is what
* `OCA\Guests\GuestManager::isGuest()` checks too. Reading the backend name
* keeps this app free of a hard dependency on the Guests app: with the app
* disabled or absent no user has that backend, and the check is simply never
* true.
*/
class EmployeeDirectory {
/**
* Backend name reported by `OCA\Guests\UserBackend::getBackendName()`.
* `IUser::getBackendClassName()` returns it for any guest account.
*/
private const GUEST_BACKEND = 'Guests';

/** @var array<string,bool> uid => is a guest, memoised per request */
private array $guestCache = [];

public function __construct(
private IUserManager $userManager,
private IGroupManager $groupManager,
) {
}

/**
* Every employee on the instance.
*
* @return string[]
*/
public function listAll(): array {
$uids = [];
$this->userManager->callForAllUsers(function (IUser $user) use (&$uids): void {
if ($this->isEmployeeUser($user)) {
$uids[] = $user->getUID();
}
});
return $uids;
}

/**
* The employees in a group, or everyone when no group is given. A group that
* does not exist yields no one, which keeps a stale group name in a report
* filter from silently widening to the whole company.
*
* @return string[]
*/
public function listInGroup(?string $group): array {
if ($group === null || $group === '') {
return $this->listAll();
}
$resolved = $this->groupManager->get($group);
if ($resolved === null) {
return [];
}
$uids = [];
foreach ($resolved->getUsers() as $user) {
if ($this->isEmployeeUser($user)) {
$uids[] = $user->getUID();
}
}
return $uids;
}

/**
* Whether this uid belongs to somebody the app may hold leave for. False for
* a guest, and for a uid that does not resolve to a user at all.
*/
public function isEmployee(string $uid): bool {
if (array_key_exists($uid, $this->guestCache)) {
return !$this->guestCache[$uid];
}
$user = $this->userManager->get($uid);
if ($user === null) {
// Not cached: an unknown uid is not a statement about guest-ness, and
// the user may be created later in a long-running job.
return false;
}
return $this->isEmployeeUser($user);
}

/** Whether this uid is a guest account specifically (an unknown uid is not). */
public function isGuest(string $uid): bool {
$user = $this->userManager->get($uid);
return $user !== null && !$this->isEmployeeUser($user);
}

/**
* Filter a list of uids down to the employees in it, preserving order.
*
* @param string[] $uids
* @return string[]
*/
public function filter(array $uids): array {
return array_values(array_filter($uids, fn (string $uid): bool => $this->isEmployee($uid)));
}

private function isEmployeeUser(IUser $user): bool {
$uid = $user->getUID();
if (!array_key_exists($uid, $this->guestCache)) {
$this->guestCache[$uid] = $user->getBackendClassName() === self::GUEST_BACKEND;
}
return !$this->guestCache[$uid];
}
}
21 changes: 4 additions & 17 deletions lib/Service/EntitlementService.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@
use OCA\Absence\Exception\NotFoundException;
use OCA\Absence\Exception\ValidationException;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\IGroupManager;
use OCP\IUserManager;
use Psr\Log\LoggerInterface;

/**
Expand All @@ -29,8 +27,7 @@ public function __construct(
private ConfigService $config,
private ClockService $clock,
private ActivityPublisher $activity,
private IUserManager $userManager,
private IGroupManager $groupManager,
private EmployeeDirectory $employees,
private LoggerInterface $logger,
) {
}
Expand Down Expand Up @@ -95,7 +92,8 @@ public function update(string $actorUid, int $id, array $data): Entitlement {
* @param array{baseDays?:float,carryOverDays?:float,manualAdjustment?:float,adjustmentNote?:string} $data
*/
public function setForEmployee(string $actorUid, string $employeeUid, int $year, int $typeId, array $data): Entitlement {
if ($this->userManager->get($employeeUid) === null) {
// Also rejects guests: they have no entitlement to set (§2.2).
if (!$this->employees->isEmployee($employeeUid)) {
throw new ValidationException('Unknown employee.');
}
$type = $this->leaveTypeMapper->find($typeId);
Expand Down Expand Up @@ -238,17 +236,6 @@ public function expireCarryOver(int $year): int {
* @return string[]
*/
private function targetUids(?string $group): array {
if ($group !== null && $group !== '') {
$g = $this->groupManager->get($group);
if ($g === null) {
return [];
}
return array_map(static fn ($u) => $u->getUID(), $g->getUsers());
}
$uids = [];
$this->userManager->callForAllUsers(static function ($user) use (&$uids): void {
$uids[] = $user->getUID();
});
return $uids;
return $this->employees->listInGroup($group);
}
}
10 changes: 9 additions & 1 deletion lib/Service/ManagerResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class ManagerResolver {

public function __construct(
private IUserManager $userManager,
private EmployeeDirectory $employees,
private LoggerInterface $logger,
) {
}
Expand Down Expand Up @@ -56,7 +57,9 @@ private function readManagerUid(IUser $user): ?string {
}
foreach ($managerUids as $uid) {
$uid = trim((string)$uid);
if ($uid !== '' && $uid !== $user->getUID() && $this->userManager->userExists($uid)) {
// A guest cannot be a line manager — they have no standing in the app,
// so routing an approval to them would strand the request (§2.2).
if ($uid !== '' && $uid !== $user->getUID() && $this->employees->isEmployee($uid)) {
return $uid;
}
}
Expand Down Expand Up @@ -102,6 +105,11 @@ private function getReportsIndex(): array {
}
$index = [];
$this->userManager->callForAllUsers(function (IUser $user) use (&$index): void {
// Guests are not employees, so they are nobody's direct report — which
// also keeps them out of getPeers() and every team-scoped view (§2.2).
if (!$this->employees->isEmployee($user->getUID())) {
return;
}
$managerUid = $this->readManagerUid($user);
if ($managerUid !== null) {
$index[$managerUid][] = $user->getUID();
Expand Down
Loading
Loading