Skip to content

Commit 5de8ad4

Browse files
fix: harden paused-entry lifecycle and recovery flow
Repair legacy paused records, allow safe edit/delete access for paused entries, and resume same-day paused sessions without creating duplicates while preserving break accounting.
1 parent 5020a43 commit 5de8ad4

7 files changed

Lines changed: 311 additions & 107 deletions

File tree

appinfo/info.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ gehosteten Nextcloud.
5454
- PHP 8.1–8.4
5555
]]></description>
5656

57-
<version>1.2.0</version>
57+
<version>1.2.1</version>
5858
<licence>AGPL-3.0-or-later</licence>
5959
<author mail="info@software-by-design.de" homepage="https://software-by-design.de">Alexander Mäule</author>
6060

lib/Controller/TimeEntryController.php

Lines changed: 32 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -452,27 +452,17 @@ public function edit(int $id): TemplateResponse
452452
return $this->configureCSP($response);
453453
}
454454

455-
// Check if entry can be edited
456-
// Allow editing if:
457-
// 1. It's a manual entry (user created it themselves) - but not if already approved
458-
// 2. It has pending approval status (correction request)
459-
// 3. It's an automatic entry that is completed (not yet approved) - allow direct editing for convenience
460-
// 4. The entry date is within the last 2 weeks (14 days) - for data integrity and compliance
461-
// Do NOT allow editing if entry is already approved (approvedBy is set) or older than 2 weeks
462-
$isApproved = $entry->getApprovedBy() !== null;
463-
$entryDate = $entry->getStartTime();
464-
$editCutoff = new \DateTime();
465-
$editCutoff->modify('-' . Constants::EDIT_WINDOW_DAYS . ' days');
466-
$editCutoff->setTime(0, 0, 0);
467-
$isWithinEditWindow = $entryDate && $entryDate >= $editCutoff;
468-
469-
$canEdit = !$isApproved && $isWithinEditWindow && (
470-
$entry->getIsManualEntry()
471-
|| $entry->getStatus() === TimeEntry::STATUS_PENDING_APPROVAL
472-
|| ($entry->getStatus() === TimeEntry::STATUS_COMPLETED && !$entry->getIsManualEntry())
473-
);
455+
// Check if entry can be edited (logic lives in TimeEntry::canEdit to stay DRY)
456+
$canEdit = $entry->canEdit(Constants::EDIT_WINDOW_DAYS);
474457

475458
if (!$canEdit) {
459+
$isApproved = $entry->getApprovedBy() !== null;
460+
$entryDate = $entry->getStartTime();
461+
$editCutoff = new \DateTime();
462+
$editCutoff->modify('-' . Constants::EDIT_WINDOW_DAYS . ' days');
463+
$editCutoff->setTime(0, 0, 0);
464+
$isWithinEditWindow = $entryDate && $entryDate >= $editCutoff;
465+
476466
$errorMessage = $isApproved
477467
? $this->l10n->t('Cannot edit this time entry. Please use "Request Correction" for approved entries.')
478468
: (!$isWithinEditWindow
@@ -781,27 +771,17 @@ public function update(int $id, ?string $date = null, ?float $hours = null, ?str
781771
return $mc0;
782772
}
783773

784-
// Check if entry can be edited
785-
// Allow editing if:
786-
// 1. It's a manual entry (user created it themselves) - but not if already approved
787-
// 2. It has pending approval status (correction request)
788-
// 3. It's an automatic entry that is completed (not yet approved) - allow direct editing for convenience
789-
// 4. The entry date is within the last 2 weeks (14 days) - for data integrity and compliance
790-
// Do NOT allow editing if entry is already approved (approvedBy is set) or older than 2 weeks
791-
$isApproved = $entry->getApprovedBy() !== null;
792-
$entryDate = $entry->getStartTime();
793-
$editCutoff = new \DateTime();
794-
$editCutoff->modify('-' . Constants::EDIT_WINDOW_DAYS . ' days');
795-
$editCutoff->setTime(0, 0, 0);
796-
$isWithinEditWindow = $entryDate && $entryDate >= $editCutoff;
797-
798-
$canEdit = !$isApproved && $isWithinEditWindow && (
799-
$entry->getIsManualEntry()
800-
|| $entry->getStatus() === TimeEntry::STATUS_PENDING_APPROVAL
801-
|| ($entry->getStatus() === TimeEntry::STATUS_COMPLETED && !$entry->getIsManualEntry())
802-
);
774+
// Check if entry can be edited (logic lives in TimeEntry::canEdit to stay DRY)
775+
$canEdit = $entry->canEdit(Constants::EDIT_WINDOW_DAYS);
803776

804777
if (!$canEdit) {
778+
$isApproved = $entry->getApprovedBy() !== null;
779+
$entryDate = $entry->getStartTime();
780+
$editCutoff = new \DateTime();
781+
$editCutoff->modify('-' . Constants::EDIT_WINDOW_DAYS . ' days');
782+
$editCutoff->setTime(0, 0, 0);
783+
$isWithinEditWindow = $entryDate && $entryDate >= $editCutoff;
784+
805785
$errorMessage = $isApproved
806786
? $this->l10n->t('Cannot edit this time entry. Please use "Request Correction" for approved entries.')
807787
: (!$isWithinEditWindow
@@ -1047,6 +1027,16 @@ public function update(int $id, ?string $date = null, ?float $hours = null, ?str
10471027
$entry->setProjectCheckProjectId($project_check_project_id);
10481028
}
10491029

1030+
// Finalise paused entries: when the user supplies an end_time, the entry
1031+
// is no longer orphaned – promote it to completed so all downstream logic
1032+
// (compliance checks, reports, approval flow) treats it correctly.
1033+
if ($entry->getStatus() === TimeEntry::STATUS_PAUSED && $entry->getEndTime() !== null) {
1034+
$entry->setStatus(TimeEntry::STATUS_COMPLETED);
1035+
if (!$entry->getEndedReason()) {
1036+
$entry->setEndedReason(TimeEntry::ENDED_REASON_MANUAL_CLOCK_OUT);
1037+
}
1038+
}
1039+
10501040
// Check rest period compliance before saving (ArbZG §5)
10511041
if ($entry->getStartTime()) {
10521042
$restPeriodCheck = $this->complianceService->checkRestPeriodForStartTime($userId, $entry->getStartTime(), $id);
@@ -1224,8 +1214,8 @@ public function getDeletionImpact(int $id): JSONResponse
12241214
], Http::STATUS_FORBIDDEN);
12251215
}
12261216

1227-
// Check if entry can be deleted
1228-
$canDelete = $entry->getIsManualEntry();
1217+
// Check if entry can be deleted (manual entries + orphaned paused entries)
1218+
$canDelete = $entry->canDelete();
12291219
$impact = [
12301220
'canDelete' => $canDelete,
12311221
'isManualEntry' => $entry->getIsManualEntry(),
@@ -1577,8 +1567,8 @@ public function delete(int $id): JSONResponse
15771567
return $mcDel;
15781568
}
15791569

1580-
// Check if entry can be deleted (only manual entries)
1581-
if (!$entry->getIsManualEntry()) {
1570+
// Check if entry can be deleted (manual entries + orphaned paused entries)
1571+
if (!$entry->canDelete()) {
15821572
return new JSONResponse([
15831573
'success' => false,
15841574
'error' => $this->l10n->t('Cannot delete automatic time entries')

lib/Db/TimeEntry.php

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,53 @@ public function isValid(): bool
453453
return empty($this->validate());
454454
}
455455

456+
/**
457+
* Whether the entry's owner may edit it.
458+
*
459+
* Centralises the business rule so controllers and templates stay in sync:
460+
* – not already approved
461+
* – start time within the edit window (default 14 days)
462+
* – status is one that allows editing: manual, pending_approval,
463+
* completed-automatic, or paused (orphaned/unfinished automatic entry)
464+
*
465+
* @param int $editWindowDays Number of past days within which editing is permitted.
466+
*/
467+
public function canEdit(int $editWindowDays = 14): bool
468+
{
469+
if ($this->approvedBy !== null) {
470+
return false;
471+
}
472+
473+
$entryDate = $this->startTime;
474+
if (!$entryDate instanceof \DateTime) {
475+
return false;
476+
}
477+
478+
$cutoff = new \DateTime();
479+
$cutoff->modify('-' . $editWindowDays . ' days');
480+
$cutoff->setTime(0, 0, 0);
481+
if ($entryDate < $cutoff) {
482+
return false;
483+
}
484+
485+
return $this->isManualEntry
486+
|| $this->status === self::STATUS_PENDING_APPROVAL
487+
|| ($this->status === self::STATUS_COMPLETED && !$this->isManualEntry)
488+
|| $this->status === self::STATUS_PAUSED;
489+
}
490+
491+
/**
492+
* Whether the entry's owner may delete it.
493+
*
494+
* Manual entries can always be deleted. Paused (orphaned automatic) entries
495+
* are also deletable because they were never properly completed and have no
496+
* approved payroll impact.
497+
*/
498+
public function canDelete(): bool
499+
{
500+
return $this->isManualEntry || $this->status === self::STATUS_PAUSED;
501+
}
502+
456503
/**
457504
* Get a summary array for API responses
458505
*
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* Repair all remaining orphaned paused time entries.
7+
*
8+
* Migration 1015 already closed paused entries with end_time IS NULL via a one-time
9+
* flag-guarded pass. This migration handles every remaining case unconditionally:
10+
*
11+
* 1. paused + end_time NOT NULL → status = completed (should never occur in normal use)
12+
* 2. paused + end_time IS NULL → end_time = updated_at (or start_time as fallback), status = completed
13+
*
14+
* This migration is idempotent: it has no effect when no paused rows remain.
15+
*
16+
* @copyright Copyright (c) 2026
17+
* @license AGPL-3.0-or-later
18+
*/
19+
20+
namespace OCA\ArbeitszeitCheck\Migration;
21+
22+
use Closure;
23+
use OCP\DB\ISchemaWrapper;
24+
use OCP\DB\QueryBuilder\IQueryBuilder;
25+
use OCP\IDBConnection;
26+
use OCP\Migration\IOutput;
27+
use OCP\Migration\SimpleMigrationStep;
28+
29+
class Version1020Date20260421000000 extends SimpleMigrationStep
30+
{
31+
public function __construct(
32+
private IDBConnection $db,
33+
) {
34+
}
35+
36+
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void
37+
{
38+
$output->info('Repairing orphaned paused time entries…');
39+
40+
try {
41+
// 1. paused entries that already have an end_time: just fix the status.
42+
$qb1 = $this->db->getQueryBuilder();
43+
$fixed1 = (int)$qb1
44+
->update('at_entries')
45+
->set('status', $qb1->createNamedParameter('completed', IQueryBuilder::PARAM_STR))
46+
->where($qb1->expr()->eq('status', $qb1->createNamedParameter('paused', IQueryBuilder::PARAM_STR)))
47+
->andWhere($qb1->expr()->isNotNull('end_time'))
48+
->executeStatement();
49+
50+
// 2. paused entries without end_time but with updated_at → end_time = updated_at.
51+
// createFunction() is used to reference the column value directly because
52+
// QueryBuilder does not support SET col = other_col via named parameters.
53+
$qb2 = $this->db->getQueryBuilder();
54+
$fixed2 = (int)$qb2
55+
->update('at_entries')
56+
->set('end_time', $qb2->createFunction('updated_at'))
57+
->set('status', $qb2->createNamedParameter('completed', IQueryBuilder::PARAM_STR))
58+
->where($qb2->expr()->eq('status', $qb2->createNamedParameter('paused', IQueryBuilder::PARAM_STR)))
59+
->andWhere($qb2->expr()->isNull('end_time'))
60+
->andWhere($qb2->expr()->isNotNull('updated_at'))
61+
->executeStatement();
62+
63+
// 3. Edge case: paused entry with neither end_time nor updated_at.
64+
// Use start_time so the entry has at least a zero-duration completed record.
65+
$qb3 = $this->db->getQueryBuilder();
66+
$fixed3 = (int)$qb3
67+
->update('at_entries')
68+
->set('end_time', $qb3->createFunction('start_time'))
69+
->set('status', $qb3->createNamedParameter('completed', IQueryBuilder::PARAM_STR))
70+
->where($qb3->expr()->eq('status', $qb3->createNamedParameter('paused', IQueryBuilder::PARAM_STR)))
71+
->andWhere($qb3->expr()->isNull('end_time'))
72+
->andWhere($qb3->expr()->isNull('updated_at'))
73+
->executeStatement();
74+
75+
$total = $fixed1 + $fixed2 + $fixed3;
76+
$output->info(sprintf('Done: %d paused entr%s repaired.', $total, $total === 1 ? 'y' : 'ies'));
77+
} catch (\Throwable $e) {
78+
// Non-fatal: log and continue. A subsequent upgrade will re-run this check.
79+
$output->warning('Could not repair paused entries: ' . $e->getMessage());
80+
\OCP\Log\logger('arbeitszeitcheck')->error(
81+
'Version1020 migration failed: ' . $e->getMessage(),
82+
['exception' => $e]
83+
);
84+
}
85+
}
86+
87+
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper
88+
{
89+
return null;
90+
}
91+
}

lib/Service/TimeTrackingService.php

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,14 @@ public function clockIn(string $userId, ?string $projectCheckProjectId = null, ?
101101
throw new \Exception($this->l10n->t('User is currently on break. End break first.'));
102102
}
103103

104+
// Check for a paused (orphaned) entry from today and resume it instead of
105+
// creating a duplicate. The pause gap is archived as a break interval so that
106+
// working-time calculations remain accurate.
107+
$pausedTodayEntry = $this->timeEntryMapper->findPausedOrUnfinishedTodayByUser($userId);
108+
if ($pausedTodayEntry !== null && $pausedTodayEntry->getStatus() === TimeEntry::STATUS_PAUSED) {
109+
return $this->resumePausedEntry($userId, $pausedTodayEntry, $projectCheckProjectId, $description);
110+
}
111+
104112
// Validate project ID length (DB column limit; ProjectCheck may not enforce)
105113
if ($projectCheckProjectId !== null && mb_strlen($projectCheckProjectId) > TimeEntry::PROJECT_CHECK_PROJECT_ID_MAX_LENGTH) {
106114
throw new \Exception($this->l10n->t('Project ID must not exceed %d characters', [TimeEntry::PROJECT_CHECK_PROJECT_ID_MAX_LENGTH]));
@@ -220,6 +228,71 @@ public function clockOut(
220228
return $updatedEntry;
221229
}
222230

231+
/**
232+
* Resume a same-day paused entry instead of creating a new one.
233+
*
234+
* The gap between the moment the entry was paused (updated_at) and now is
235+
* archived as a break interval so that working-time calculations stay correct.
236+
* Project and description are updated when the caller supplies new values.
237+
*
238+
* @throws \Exception When the month is already closed or the daily maximum is reached.
239+
*/
240+
private function resumePausedEntry(
241+
string $userId,
242+
TimeEntry $pausedEntry,
243+
?string $projectCheckProjectId = null,
244+
?string $description = null
245+
): TimeEntry {
246+
$this->monthClosureGuard->assertTimeEntryMutable($pausedEntry);
247+
248+
// Respect the daily maximum – the paused entry's own hours already count.
249+
$today = new \DateTime();
250+
$today->setTime(0, 0, 0);
251+
$tomorrow = clone $today;
252+
$tomorrow->modify('+1 day');
253+
$todayHours = $this->timeEntryMapper->getTotalHoursByUserAndDateRange($userId, $today, $tomorrow);
254+
$maxDailyHours = $this->getMaxDailyHours();
255+
if ($todayHours >= $maxDailyHours) {
256+
throw new \Exception($this->l10n->t(
257+
'Cannot clock in: Maximum daily working hours (%1$dh) already reached. You have already worked %2$.1f hours today (ArbZG §3).',
258+
[(int)$maxDailyHours, $todayHours]
259+
));
260+
}
261+
262+
$now = new \DateTime();
263+
$pausedAt = $pausedEntry->getUpdatedAt() ?? $now;
264+
265+
// Archive the gap since the entry was paused as a break so it is excluded
266+
// from net working time. Only do so when the gap is positive.
267+
if ($pausedAt < $now) {
268+
$this->archiveBreakToJson($pausedEntry, $pausedAt, $now);
269+
}
270+
271+
// Update optional fields if the caller supplied new values.
272+
if ($projectCheckProjectId !== null) {
273+
$pausedEntry->setProjectCheckProjectId($projectCheckProjectId);
274+
}
275+
if ($description !== null) {
276+
$pausedEntry->setDescription($description);
277+
}
278+
279+
$pausedEntry->setStatus(TimeEntry::STATUS_ACTIVE);
280+
$pausedEntry->setUpdatedAt($now);
281+
282+
$resumed = $this->timeEntryMapper->update($pausedEntry);
283+
284+
$this->auditLogMapper->logAction(
285+
$userId,
286+
'clock_in',
287+
'time_entry',
288+
$resumed->getId(),
289+
null,
290+
$this->safeGetSummary($resumed, $userId)
291+
);
292+
293+
return $resumed;
294+
}
295+
223296
/**
224297
* Start break for a user
225298
*
@@ -345,8 +418,33 @@ public function getStatus(string $userId): array
345418
$currentEntry = $activeEntry ?: $breakEntry;
346419
$currentEntry = $this->enforceBreakAutoFallbackForUser($userId, $currentEntry);
347420

348-
// If no active entry we are fully clocked out.
421+
// If no active/break entry, check for a paused entry from today before
422+
// declaring the user fully clocked out.
349423
if ($currentEntry === null) {
424+
$pausedEntry = $this->timeEntryMapper->findPausedOrUnfinishedTodayByUser($userId);
425+
if ($pausedEntry !== null && $pausedEntry->getStatus() === TimeEntry::STATUS_PAUSED) {
426+
$sessionStart = $pausedEntry->getStartTime();
427+
$pausedAt = $pausedEntry->getUpdatedAt() ?? new \DateTime();
428+
$sessionDuration = $sessionStart
429+
? max(0, $pausedAt->getTimestamp() - $sessionStart->getTimestamp() - (int)($pausedEntry->getBreakDurationHours() * 3600))
430+
: 0;
431+
try {
432+
$pausedSummary = $pausedEntry->getSummary();
433+
} catch (\Throwable $e) {
434+
\OCP\Log\logger('arbeitszeitcheck')->error(
435+
'Error getting summary for paused entry in getStatus: ' . $e->getMessage(),
436+
['exception' => $e]
437+
);
438+
$pausedSummary = ['id' => $pausedEntry->getId(), 'userId' => $userId, 'status' => TimeEntry::STATUS_PAUSED];
439+
}
440+
return $this->appendAutoClockoutNotice([
441+
'status' => TimeEntry::STATUS_PAUSED,
442+
'current_entry' => $pausedSummary,
443+
'working_today_hours' => $this->getTodayHours($userId),
444+
'current_session_duration' => $sessionDuration,
445+
], $userId);
446+
}
447+
350448
return $this->appendAutoClockoutNotice([
351449
'status' => 'clocked_out',
352450
'current_entry' => null,

0 commit comments

Comments
 (0)