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
32 changes: 31 additions & 1 deletion SPECIFICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,13 +240,39 @@ happened and when. One row is written for every meaningful transition.
| `request_id` | bigint FK, indexed | The request this event belongs to. |
| `actor_uid` | string(64) | Who performed the action; the literal `system` for automated events (e.g. escalation). |
| `event_type` | string(32) | Machine key: `request_created`, `request_updated`, `request_edited_superseding`, `request_hr_edited`, `withdrawal_requested`, `request_cancelled`, `withdrawal_approved`, `request_approved`, `request_rejected`, `withdrawal_rejected`, `request_escalated`, `comment_added`. |
| `detail` | text, nullable | Human-readable extra (decision comment, new date range, comment body, "auto-approved", …). |
| `detail` | text, nullable | Human-readable extra. For an edit this is the **difference**, not the result: `Working days 3 → 5 (+2); Reason “Wedding” → “Wedding (extended)”`. Recording only the resulting state cannot answer what anybody opens the history to ask — what changed and by how much — and a day count means nothing without the number it replaced. On creation it carries the type, dates, day count and the employee's reason, since the request itself only ever shows its *current* state. |
| `created_at` | datetime | |

Events are written by the same `audit()` path that emits the server-log entry (§11),
so history, server log and activity stay in sync from a single call site. History
writes are best-effort — a failure never blocks the workflow.

### 3.7b `absence_entitlement_events` (entitlement history)

The same idea for entitlements, which had no timeline at all: §3.7 is keyed on
`request_id`, and an entitlement belongs to no request, so an adjustment left only a
server-log line and an activity entry reading "Leave balance of X was adjusted" —
with neither the amount nor the reason. Worse, the note HR is *required* to give
when adjusting was stored on the entitlement row, displayed nowhere, and overwritten
by the next adjustment.

| Column | Type | Notes |
|--------|------|-------|
| `id` | bigint, PK | |
| `entitlement_id` | bigint, indexed | The entitlement this change belongs to. |
| `employee_uid` | string(64), indexed | Denormalised from the entitlement so the GDPR purge (§17) and per-person views need no join to a row that is about to be deleted. |
| `actor_uid` | string(64) | Who made the change. |
| `field` | string(32) | `base_days`, `carry_over_days` or `manual_adjustment`. |
| `old_value` / `new_value` | float | The figure before and after; the delta is derived. |
| `note` | text, nullable | The reason given, attached to every figure that save touched. |
| `created_at` | datetime | |

**One row per changed figure, not per save,** so "+2 days for the wedding" reads on
its own. A save that moves nothing writes nothing. Surfaced in the entitlement
editor in HR → Balances, and carried into the activity entry so it says what
changed rather than only that something did. Best-effort, like §3.7: an unwritable
history must not cost HR the adjustment they just made.

### 3.8 Attachments (optional, phase 2)

For doctor's notes: allow attaching a file reference stored in the user's Files.
Expand Down Expand Up @@ -745,6 +771,10 @@ the SPA). All list endpoints paginate and accept filters.
- `GET /api/employees/{uid}/balance` — manager (reports) / HR only.
- `GET /api/entitlements` / `PUT /api/entitlements/{id}` — HR manage.
- `POST /api/entitlements/bulk` — HR bulk set.
- `GET /api/entitlements/{id}/history` — HR only: who changed which figure on an
entitlement, from what to what, and the note they gave. One row per figure per
save, so a single adjustment reads on its own rather than having to be diffed
out of a blob.

**Coverage & calendar**
- `GET /api/coverage?from&to&scope=team|company` — overlaps + conflict count (§8).
Expand Down
1 change: 1 addition & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
['name' => 'entitlement#create', 'url' => '/api/entitlements', 'verb' => 'POST'],
['name' => 'entitlement#update', 'url' => '/api/entitlements/{id}', 'verb' => 'PUT'],
['name' => 'entitlement#bulk', 'url' => '/api/entitlements/bulk', 'verb' => 'POST'],
['name' => 'entitlement#history', 'url' => '/api/entitlements/{id}/history', 'verb' => 'GET'],

// Coverage & calendar
['name' => 'coverage#index', 'url' => '/api/coverage', 'verb' => 'GET'],
Expand Down

Large diffs are not rendered by default.

Large diffs are not rendered by default.

16 changes: 8 additions & 8 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-CAVYKD4e.chunk.mjs → js/index-YEWpjbJf.chunk.mjs

Large diffs are not rendered by default.

Large diffs are not rendered by default.

24 changes: 23 additions & 1 deletion lib/Activity/Provider.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ public function parse($language, IEvent $event, ?IEvent $previousEvent = null):
ActivityPublisher::SUBJECT_CANCELLED => $l->t('Leave for %1$s (%2$s) was cancelled', [$employee, $range]),
ActivityPublisher::SUBJECT_ESCALATED => $l->t('Leave for %1$s (%2$s) was escalated to HR', [$employee, $range]),
ActivityPublisher::SUBJECT_WITHDRAWAL => $l->t('%1$s requested to withdraw leave for %2$s', [$employee, $range]),
ActivityPublisher::SUBJECT_BALANCE_ADJUSTED => $l->t('Leave balance of %s was adjusted', [$employee]),
ActivityPublisher::SUBJECT_BALANCE_ADJUSTED => $this->balanceAdjusted($l, $employee, $params),
default => throw new UnknownActivityException('Unknown subject'),
};

Expand All @@ -54,6 +54,28 @@ public function parse($language, IEvent $event, ?IEvent $previousEvent = null):
return $event;
}

/**
* "Leave balance of X was adjusted" told nobody anything: not by how much, not
* which figure, and not why — while HR is *required* to give a reason. The
* numbers are carried on the event, so say them.
*
* Older entries carry neither key and still have to render, so both are
* optional and the bare sentence remains the fallback.
*
* @param array<string,mixed> $params
*/
private function balanceAdjusted(\OCP\IL10N $l, string $employee, array $params): string {
$summary = trim((string)($params['summary'] ?? ''));
$note = trim((string)($params['note'] ?? ''));
if ($summary === '') {
return $l->t('Leave balance of %s was adjusted', [$employee]);
}
if ($note === '') {
return $l->t('Leave balance of %1$s was adjusted: %2$s', [$employee, $summary]);
}
return $l->t('Leave balance of %1$s was adjusted: %2$s (%3$s)', [$employee, $summary, $note]);
}

private function displayName(string $uid): string {
if ($uid === '') {
return '';
Expand Down
16 changes: 16 additions & 0 deletions lib/Controller/EntitlementController.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,22 @@ public function update(int $id, ?float $baseDays = null, ?float $carryOverDays =
});
}

/**
* Who changed this entitlement, which figure, from what to what, and why (§6.1).
* HR only, like every other entitlement endpoint.
*/
#[NoAdminRequired]
#[UserRateLimit(limit: 60, period: 60)]
public function history(int $id): DataResponse {
return $this->handle(function () use ($id) {
$this->permission->assertHr((string)$this->userId);
return array_map(
static fn ($event) => $event->jsonSerialize(),
$this->service->historyFor($id),
);
});
}

#[NoAdminRequired]
#[UserRateLimit(limit: 10, period: 60)]
public function bulk(int $year, int $typeId, float $baseDays, ?string $group = null): DataResponse {
Expand Down
72 changes: 72 additions & 0 deletions lib/Db/EntitlementEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

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

namespace OCA\Absence\Db;

use OCP\AppFramework\Db\Entity;

/**
* One recorded change to one figure on an entitlement (§6.1).
*
* @method string getEmployeeUid()
* @method void setEmployeeUid(string $employeeUid)
* @method int getEntitlementId()
* @method void setEntitlementId(int $entitlementId)
* @method string getActorUid()
* @method void setActorUid(string $actorUid)
* @method string getField()
* @method void setField(string $field)
* @method float getOldValue()
* @method void setOldValue(float $oldValue)
* @method float getNewValue()
* @method void setNewValue(float $newValue)
* @method string|null getNote()
* @method void setNote(?string $note)
* @method \DateTime getCreatedAt()
* @method void setCreatedAt(\DateTime $createdAt)
*/
class EntitlementEvent extends Entity implements \JsonSerializable {
/** The figures an entitlement is made of, and which this records changes to. */
public const FIELD_BASE_DAYS = 'base_days';
public const FIELD_CARRY_OVER_DAYS = 'carry_over_days';
public const FIELD_MANUAL_ADJUSTMENT = 'manual_adjustment';

protected int $entitlementId = 0;
protected string $employeeUid = '';
protected string $actorUid = '';
protected string $field = '';
protected float $oldValue = 0.0;
protected float $newValue = 0.0;
protected ?string $note = null;
protected ?\DateTime $createdAt = null;

public function __construct() {
$this->addType('entitlementId', 'integer');
$this->addType('oldValue', 'float');
$this->addType('newValue', 'float');
$this->addType('createdAt', 'datetime');
}

#[\Override]
public function jsonSerialize(): array {
return [
'id' => $this->id,
'entitlementId' => $this->entitlementId,
'employeeUid' => $this->employeeUid,
'actorUid' => $this->actorUid,
'field' => $this->field,
'oldValue' => $this->oldValue,
'newValue' => $this->newValue,
// The client renders "+2" rather than re-deriving it from the two values,
// so the sign it shows and the one the audit log records cannot drift.
'delta' => round($this->newValue - $this->oldValue, 1),
'note' => $this->note,
'createdAt' => $this->createdAt?->format(\DateTimeInterface::ATOM),
];
}
}
68 changes: 68 additions & 0 deletions lib/Db/EntitlementEventMapper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php

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

namespace OCA\Absence\Db;

use OCP\AppFramework\Db\QBMapper;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;

/**
* @extends QBMapper<EntitlementEvent>
*/
class EntitlementEventMapper extends QBMapper {
public function __construct(IDBConnection $db) {
parent::__construct($db, 'absence_entitlement_events', EntitlementEvent::class);
}

/**
* The full chronological history for one entitlement, oldest first.
*
* The id tiebreaker matters here: several figures can change in one save and
* therefore share a timestamp to the second, and without it the order they are
* shown in could differ between loads.
*
* @return EntitlementEvent[]
*/
public function findForEntitlement(int $entitlementId): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where($qb->expr()->eq('entitlement_id', $qb->createNamedParameter($entitlementId, IQueryBuilder::PARAM_INT)))
->orderBy('created_at', 'ASC')
->addOrderBy('id', 'ASC');
return $this->findEntities($qb);
}

/**
* Every recorded change for one employee, newest first — the whole story of
* their allowance across years and leave types.
*
* @return EntitlementEvent[]
*/
public function findForEmployee(string $employeeUid, ?int $limit = null): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where($qb->expr()->eq('employee_uid', $qb->createNamedParameter($employeeUid)))
->orderBy('created_at', 'DESC')
->addOrderBy('id', 'DESC');
if ($limit !== null) {
$qb->setMaxResults($limit);
}
return $this->findEntities($qb);
}

/** Used by the GDPR purge when an account is deleted (§17). */
public function deleteForEmployee(string $employeeUid): void {
$qb = $this->db->getQueryBuilder();
$qb->delete($this->getTableName())
->where($qb->expr()->eq('employee_uid', $qb->createNamedParameter($employeeUid)));
$qb->executeStatement();
}
}
5 changes: 4 additions & 1 deletion lib/Listener/UserDeletedListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,12 @@ public function handle(Event $event): void {
}
$this->deleteWhereEquals('absence_comments', 'author_uid', $uid);

// Remove the user's requests and entitlements.
// Remove the user's requests and entitlements, and the record of who changed
// those entitlements — it names the employee and is about their allowance, so
// it goes with them (§17).
$this->deleteWhereEquals('absence_requests', 'employee_uid', $uid);
$this->deleteWhereEquals('absence_entitlements', 'employee_uid', $uid);
$this->deleteWhereEquals('absence_entitlement_events', 'employee_uid', $uid);

// Detach the user as a manager or replacement from any remaining requests.
foreach (['manager_uid', 'replacement_uid'] as $column) {
Expand Down
62 changes: 62 additions & 0 deletions lib/Migration/Version1004Date20260812120000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php

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

namespace OCA\Absence\Migration;

use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\DB\Types;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;

/**
* Adds `absence_entitlement_events`: the chronological record of who changed an
* entitlement, which figure they changed, from what to what, and why (§6.1).
*
* Entitlement changes had nowhere to be recorded. Leave requests have their own
* timeline in `absence_request_events`, but that table is keyed on `request_id`
* and an entitlement belongs to no request — so an adjustment left only a line in
* `nextcloud.log` and an activity entry reading "Leave balance of X was adjusted",
* with neither the amount nor the reason. The note HR is *required* to write when
* adjusting was stored on the entitlement row and displayed nowhere, and was
* overwritten by the next adjustment.
*
* One row per changed figure rather than per save, so "+2 days for the wedding"
* is a fact that can be read on its own instead of being diffed out of a blob.
*/
class Version1004Date20260812120000 extends SimpleMigrationStep {
#[\Override]
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();

if ($schema->hasTable('absence_entitlement_events')) {
return null;
}

$table = $schema->createTable('absence_entitlement_events');
$table->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'notnull' => true]);
$table->addColumn('entitlement_id', Types::BIGINT, ['notnull' => true]);
// Denormalised from the entitlement so the GDPR purge and any per-person
// view can work without joining a row that is about to be deleted.
$table->addColumn('employee_uid', Types::STRING, ['notnull' => true, 'length' => 64]);
$table->addColumn('actor_uid', Types::STRING, ['notnull' => true, 'length' => 64]);
// 'base_days' | 'carry_over_days' | 'manual_adjustment'
$table->addColumn('field', Types::STRING, ['notnull' => true, 'length' => 32]);
$table->addColumn('old_value', Types::FLOAT, ['notnull' => true, 'default' => 0]);
$table->addColumn('new_value', Types::FLOAT, ['notnull' => true, 'default' => 0]);
$table->addColumn('note', Types::TEXT, ['notnull' => false]);
$table->addColumn('created_at', Types::DATETIME, ['notnull' => true]);

$table->setPrimaryKey(['id']);
$table->addIndex(['entitlement_id'], 'absence_entev_ent');
$table->addIndex(['employee_uid'], 'absence_entev_emp');

return $schema;
}
}
Loading
Loading