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
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ Built to the specification in [SPECIFICATION.md](./SPECIFICATION.md).
and CSV export.
- **Calendar sync**: approved leave is written to a personal and a shared team calendar
via CalDAV.
- **Notifications, email and activity** for every step.
- **Notifications, email and activity** for every step — including whatever the
employee, manager or HR wrote: the reason on the request, the decision comment and
new comments are quoted in the notification and the email, so nobody has to open
the app to find out what was said.
- Native Nextcloud UI (Vue 3 + `@nextcloud/vue`), dark-mode aware, translatable.

## Screenshots
Expand Down Expand Up @@ -161,7 +164,10 @@ withdrawal requested/approved/rejected, escalation, comments, entitlement change
carry-over rollover/expiry, leave-type and holiday changes, admin-config changes, and
GDPR user-data purge — is written to **`nextcloud.log`** as a structured JSON entry
tagged `"app":"absence"` with a machine-readable `action` and full context (actor,
request id, employee, type, dates, working days, status).
request id, employee, type, dates, working days, status). Where the action carried
free text, the text is part of the entry too: `detail` holds a comment body or a
decision comment, `reason` holds the note the applicant wrote when creating the
request.

These entries are **always written regardless of the instance log level**: on install
(and every update) the app adds `absence` to the system `log.condition.apps` list,
Expand Down
17 changes: 15 additions & 2 deletions SPECIFICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -522,21 +522,34 @@ All four channels are required.

- **Nextcloud notifications:** implement `OCP\Notification\INotifier`. Events:
new request (→ manager), decision made (→ employee), escalation (→ HR),
reminder (→ manager), withdrawal request (→ manager/HR), **replacement assigned**
reminder (→ manager), withdrawal request (→ manager/HR), **comment added**
(→ the employee and their manager, plus HR once the request has been escalated;
never back to the comment's own author), **replacement assigned**
(→ replacement, on approval) and **replacement cancelled** (→ replacement, when
approved leave is cancelled) — §5.1. These are pushed (a standard NC notification is
delivered to push automatically). Provide actionable notifications (Approve/Reject
buttons where feasible) linking into the app.
- **Email:** via `OCP\Mail\IMailer` with templated messages
(`OCP\Mail\IEMailTemplate`) for each of the above events. Respect the user's
configured email + language.
- **What people wrote travels with the message.** Free text on a request — the
applicant's `reason`, the `decision_comment`, the body of a comment (§3.6) — is
carried by the notification and the email that announce the event, attributed to
its author. A recipient must never have to open the app to find out what was
actually said. The email quotes the text in full; the notification, which renders
on one line, carries a whitespace-collapsed opening of it and where a note is
present it takes the place of boilerplate like "Review it in Absence.".
One deliberate exception: the replacement (§5.1) is told the dates only, never the
reason — cover duty does not come with a right to read it.
- **Activity:** implement `OCP\Activity\IProvider` / setting so all state changes
appear in the Activity app feed, filterable to an "Absence" activity type. Include
activity for HR overrides and balance adjustments.
- **Server-log audit trail (always-on):** every important action is written to
`nextcloud.log` as a structured entry tagged `["app" => "absence"]` with a
machine-readable `action` and full context (actor, request id, employee, type,
dates, working days, status). Covered actions: the full request lifecycle (create,
dates, working days, status) plus, where the action carried free text, that text
itself — `detail` for a comment body or decision comment, `reason` for the note
the applicant wrote on creation. Covered actions: the full request lifecycle (create,
edit, superseding edit, HR edit, approve, reject, cancel, withdrawal
request/approve/reject, escalate, comment), entitlement changes, bulk-set,
carry-over rollover/expiry, leave-type and holiday changes, admin-config changes,
Expand Down
16 changes: 16 additions & 0 deletions lib/Notification/Notifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ public function prepare(INotification $notification, string $languageCode): INot
$params = $notification->getSubjectParameters();
$employee = $this->displayName((string)($params['employee'] ?? ''));
$requestId = (string)($params['requestId'] ?? $notification->getObjectId());
// Notifications stored before notes were carried have neither key.
$note = trim((string)($params['note'] ?? ''));
$noteAuthor = $this->displayName((string)($params['noteAuthor'] ?? ''));

[$subject, $message] = match ($notification->getSubject()) {
NotificationService::SUBJECT_NEW_REQUEST => [
Expand Down Expand Up @@ -82,9 +85,22 @@ public function prepare(INotification $notification, string $languageCode): INot
$l->t('No longer covering for %s', [$employee]),
$l->t('Their leave was cancelled.'),
],
NotificationService::SUBJECT_COMMENT => [
$noteAuthor !== ''
? $l->t('%s commented on a leave request', [$noteAuthor])
: $l->t('New comment on a leave request'),
'',
],
default => throw new UnknownNotificationException('Unknown subject'),
};

// What someone actually wrote beats the boilerplate that would otherwise fill
// this line: "Review it in Absence." says nothing the Review button doesn't,
// while the reason or decision comment is the reason to look at all.
if ($note !== '') {
$message = $note;
}

$notification->setParsedSubject($subject);
if ($message !== '') {
$notification->setParsedMessage($message);
Expand Down
114 changes: 97 additions & 17 deletions lib/Service/NotificationService.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ class NotificationService {
public const SUBJECT_WITHDRAWAL_REJECTED = 'withdrawal_rejected';
public const SUBJECT_REPLACEMENT_ASSIGNED = 'replacement_assigned';
public const SUBJECT_REPLACEMENT_CANCELLED = 'replacement_cancelled';
public const SUBJECT_COMMENT = 'comment_added';

/**
* How much of a note survives into a Nextcloud notification. The free text is
* copied into every recipient's notification row and rendered on a single line
* there, so the notification carries a readable opening and the email — which
* has room for it — carries the whole thing.
*/
private const NOTE_PREVIEW_LENGTH = 200;

public function __construct(
private INotificationManager $notificationManager,
Expand All @@ -44,22 +53,32 @@ public function __construct(
}

public function notifyNewRequest(LeaveRequest $request, string $managerUid): void {
$this->send($managerUid, self::SUBJECT_NEW_REQUEST, $request, true);
$this->send($managerUid, self::SUBJECT_NEW_REQUEST, $request, true, $request->getReason(), $request->getEmployeeUid());
}

/** @param string[] $hrUids */
public function notifyEscalation(LeaveRequest $request, array $hrUids): void {
foreach ($hrUids as $uid) {
$this->send($uid, self::SUBJECT_ESCALATION, $request, true);
$this->send($uid, self::SUBJECT_ESCALATION, $request, true, $request->getReason(), $request->getEmployeeUid());
}
}

public function notifyDecision(LeaveRequest $request, bool $approved): void {
$this->send($request->getEmployeeUid(), $approved ? self::SUBJECT_APPROVED : self::SUBJECT_REJECTED, $request, false);
// The decision comment is the whole substance of the message for a rejection
// and the manager's note on an approval — read it off the request the caller
// just wrote rather than making every call site pass it again.
$this->send(
$request->getEmployeeUid(),
$approved ? self::SUBJECT_APPROVED : self::SUBJECT_REJECTED,
$request,
false,
$request->getDecisionComment(),
$request->getDecidedBy(),
);
}

public function notifyReminder(LeaveRequest $request, string $managerUid): void {
$this->send($managerUid, self::SUBJECT_REMINDER, $request, true);
$this->send($managerUid, self::SUBJECT_REMINDER, $request, true, $request->getReason(), $request->getEmployeeUid());
}

/** @param string[] $recipientUids */
Expand All @@ -69,9 +88,29 @@ public function notifyWithdrawal(LeaveRequest $request, array $recipientUids): v
}
}

/** Tell the employee their withdrawal was declined — the leave stays approved. */
public function notifyWithdrawalRejected(LeaveRequest $request): void {
$this->send($request->getEmployeeUid(), self::SUBJECT_WITHDRAWAL_REJECTED, $request, false);
/**
* Tell the employee their withdrawal was declined — the leave stays approved.
* The reason lives in a comment rather than on the request (see
* {@see RequestService::reject()}), so the caller has to hand it over.
*/
public function notifyWithdrawalRejected(LeaveRequest $request, ?string $comment = null, ?string $actorUid = null): void {
$this->send($request->getEmployeeUid(), self::SUBJECT_WITHDRAWAL_REJECTED, $request, false, $comment, $actorUid);
}

/**
* Tell the other people on a request that someone commented on it. Without
* this a comment only exists behind the request's Comments tab, which nobody
* opens unless they already know there is something to read.
*
* @param string[] $recipientUids
*/
public function notifyComment(LeaveRequest $request, string $authorUid, string $body, array $recipientUids): void {
foreach (array_unique(array_filter($recipientUids)) as $uid) {
if ($uid === $authorUid) {
continue;
}
$this->send($uid, self::SUBJECT_COMMENT, $request, false, $body, $authorUid);
}
}

/** Tell the nominated replacement they now cover for the employee (§5.1). */
Expand All @@ -90,12 +129,20 @@ public function notifyReplacementCancelled(LeaveRequest $request): void {
}
}

private function send(string $recipientUid, string $subject, LeaveRequest $request, bool $actionable): void {
$this->sendNotification($recipientUid, $subject, $request, $actionable);
$this->sendEmail($recipientUid, $subject, $request);
/**
* @param ?string $note free text written by a person that the recipient would
* otherwise only find by opening the request: the
* applicant's reason, a decision comment, or a comment
* left on the request
* @param ?string $noteAuthorUid who wrote $note, so it can be attributed
*/
private function send(string $recipientUid, string $subject, LeaveRequest $request, bool $actionable, ?string $note = null, ?string $noteAuthorUid = null): void {
$note = trim((string)$note);
$this->sendNotification($recipientUid, $subject, $request, $actionable, $note, $noteAuthorUid);
$this->sendEmail($recipientUid, $subject, $request, $note, $noteAuthorUid);
}

private function sendNotification(string $recipientUid, string $subject, LeaveRequest $request, bool $actionable): void {
private function sendNotification(string $recipientUid, string $subject, LeaveRequest $request, bool $actionable, string $note, ?string $noteAuthorUid): void {
try {
$notification = $this->notificationManager->createNotification();
$notification->setApp(ConfigService::APP_ID)
Expand All @@ -106,14 +153,28 @@ private function sendNotification(string $recipientUid, string $subject, LeaveRe
'employee' => $request->getEmployeeUid(),
'requestId' => (string)$request->getId(),
'actionable' => $actionable,
'note' => $this->preview($note),
'noteAuthor' => (string)$noteAuthorUid,
]);
$this->notificationManager->notify($notification);
} catch (\Throwable $e) {
$this->logger->warning('Absence: notification failed', ['exception' => $e]);
}
}

private function sendEmail(string $recipientUid, string $subject, LeaveRequest $request): void {
/**
* Squeeze a note onto the single line a notification gives it: collapse the
* line breaks it may contain and cut it to length. The email carries the rest.
*/
private function preview(string $note): string {
$note = trim((string)preg_replace('/\s+/u', ' ', $note));
if (mb_strlen($note) <= self::NOTE_PREVIEW_LENGTH) {
return $note;
}
return mb_substr($note, 0, self::NOTE_PREVIEW_LENGTH - 1) . '…';
}

private function sendEmail(string $recipientUid, string $subject, LeaveRequest $request, string $note, ?string $noteAuthorUid): void {
$user = $this->userManager->get($recipientUid);
if (!$user instanceof IUser) {
return;
Expand All @@ -125,13 +186,24 @@ private function sendEmail(string $recipientUid, string $subject, LeaveRequest $
try {
$lang = $this->l10nFactory->getUserLanguage($user);
$l = $this->l10nFactory->get(ConfigService::APP_ID, $lang);
[$heading, $body] = $this->emailContent($l, $subject, $request);
[$heading, $body] = $this->emailContent($l, $subject, $request, $noteAuthorUid);

$template = $this->mailer->createEMailTemplate('absence.' . $subject);
$template->setSubject($heading);
$template->addHeader();
$template->addHeading($heading);
$template->addBodyText($body);
// Quote the note verbatim under the summary, attributed. addBodyListItem
// escapes the text and keeps its line breaks, so a comment written as
// several lines still reads as several lines.
if ($note !== '') {
$template->addBodyListItem(
$note,
$noteAuthorUid !== null && $noteAuthorUid !== ''
? $l->t('%s wrote:', [$this->displayName($noteAuthorUid)])
: $l->t('Comment:'),
);
}
$template->addBodyButton(
$l->t('Open Absence'),
$this->urlGenerator->linkToRouteAbsolute('absence.page.index') . '#/requests/' . $request->getId(),
Expand All @@ -150,8 +222,9 @@ private function sendEmail(string $recipientUid, string $subject, LeaveRequest $
/**
* @return array{0:string,1:string} heading and body
*/
private function emailContent(\OCP\IL10N $l, string $subject, LeaveRequest $request): array {
private function emailContent(\OCP\IL10N $l, string $subject, LeaveRequest $request, ?string $noteAuthorUid): array {
$employee = $this->displayName($request->getEmployeeUid());
$author = $noteAuthorUid !== null && $noteAuthorUid !== '' ? $this->displayName($noteAuthorUid) : '';
$range = $request->getStartDate() . ' – ' . $request->getEndDate();
return match ($subject) {
self::SUBJECT_NEW_REQUEST => [
Expand All @@ -168,7 +241,8 @@ private function emailContent(\OCP\IL10N $l, string $subject, LeaveRequest $requ
],
self::SUBJECT_REJECTED => [
$l->t('Your leave was declined'),
$l->t('Your leave request for %1$s was declined. %2$s', [$range, (string)$request->getDecisionComment()]),
// The reason follows as the quoted note; it is required on a rejection.
$l->t('Your leave request for %s was declined.', [$range]),
],
self::SUBJECT_REMINDER => [
$l->t('Reminder: leave request from %s', [$employee]),
Expand All @@ -181,8 +255,10 @@ private function emailContent(\OCP\IL10N $l, string $subject, LeaveRequest $requ
self::SUBJECT_WITHDRAWAL_REJECTED => [
$l->t('Your withdrawal request was declined'),
// The refusal reason is recorded as a comment on the request, not in
// decision_comment (that still holds the original approval note).
$l->t('Your request to withdraw the leave for %s was declined — the leave stays approved. See the comments on the request for the reason.', [$range]),
// decision_comment (that still holds the original approval note) — it
// is passed in as the note and quoted below, so there is no need to
// send the employee looking for it.
$l->t('Your request to withdraw the leave for %s was declined — the leave stays approved.', [$range]),
],
self::SUBJECT_REPLACEMENT_ASSIGNED => [
$l->t('You are covering for %s', [$employee]),
Expand All @@ -192,6 +268,10 @@ private function emailContent(\OCP\IL10N $l, string $subject, LeaveRequest $requ
$l->t('No longer covering for %s', [$employee]),
$l->t('%1$s\'s leave for %2$s was cancelled — you no longer need to cover.', [$employee, $range]),
],
self::SUBJECT_COMMENT => [
$author !== '' ? $l->t('New comment from %s', [$author]) : $l->t('New comment on a leave request'),
$l->t('There is a new comment on the leave request of %1$s for %2$s.', [$employee, $range]),
],
default => [$l->t('Absence update'), $l->t('There is an update on a leave request.')],
};
}
Expand Down
27 changes: 25 additions & 2 deletions lib/Service/RequestService.php
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,14 @@ public function create(string $actorUid, array $data): LeaveRequest {
$this->notifications->notifyNewRequest($request, (string)$managerUid);
$this->activity->publish(ActivityPublisher::SUBJECT_CREATED, $this->activityParams($request), [$employeeUid, (string)$managerUid], $request);
}
$this->audit('request_created', $request, ['actor' => $actorUid, 'detail' => $this->createdDetail($request, $type, $onBehalf)]);
// `reason` is the employee's own note on the request; the log entry is the one
// place it can be reconstructed later, so it goes into the context rather than
// into `detail` (the Details tab already shows it in the request's history).
$this->audit('request_created', $request, [
'actor' => $actorUid,
'detail' => $this->createdDetail($request, $type, $onBehalf),
'reason' => $request->getReason(),
]);
return $request;
}

Expand Down Expand Up @@ -705,7 +712,7 @@ public function reject(string $actorUid, int $id, string $comment): LeaveRequest
$this->notifications->dismiss($request);
// A declined withdrawal is not an approval — tell the employee their
// leave stands, not "your leave was approved, enjoy!".
$this->notifications->notifyWithdrawalRejected($request);
$this->notifications->notifyWithdrawalRejected($request, $comment, $actorUid);
$this->activity->publish(ActivityPublisher::SUBJECT_APPROVED, $this->activityParams($request), [$request->getEmployeeUid(), $actorUid], $request);
$this->audit('withdrawal_rejected', $request, ['actor' => $actorUid, 'detail' => $comment]);
return $request;
Expand Down Expand Up @@ -747,9 +754,25 @@ public function addComment(string $actorUid, int $id, string $body): RequestComm
$comment->setCreatedAt($this->clock->now());
$comment = $this->commentMapper->insert($comment);
$this->audit('comment_added', $request, ['actor' => $actorUid, 'detail' => $body]);
$this->notifications->notifyComment($request, $actorUid, $body, $this->commentRecipients($request));
return $comment;
}

/**
* Who hears about a comment: the employee and their line manager, plus HR once
* the request has been through them, so a question HR asked does not sit
* unanswered. The author is filtered out by the notification service.
*
* @return string[]
*/
private function commentRecipients(LeaveRequest $request): array {
$recipients = [$request->getEmployeeUid(), (string)$request->getManagerUid()];
if ($request->getEscalated()) {
$recipients = [...$recipients, ...$this->permission->getHrUids()];
}
return array_values(array_unique(array_filter($recipients)));
}

// --------------------------------------------------------------- helpers ----

/**
Expand Down
Loading
Loading