docs : fix plan review findings for Zimbra calendar integration

- Separate @Version from occurrenceCount (use dedicated version column)
- Fix processor chaining: TaskNumberProcessor for Post, TaskCalendarProcessor for Patch/Delete
- Detect status CHANGE to isFinal (not just current isFinal) to avoid duplicate recurrence
- Add DeleteTaskTool CalDAV cleanup for MCP deletions
- Add "Mes tâches" page update task (sort + columns)
- Use i18n for weekDays labels instead of hardcoded French
- Clarify documents/bookStackLinks NOT copied for recurring tasks
- Use multi-line getter/setter style note

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-03-19 10:02:16 +01:00
parent 30fb36e668
commit 98370e0478

View File

@@ -174,10 +174,13 @@ class TaskRecurrence
private ?int $maxOccurrences = null;
#[ORM\Column(type: 'integer', options: ['default' => 0])]
#[ORM\Version]
#[Groups(['task_recurrence:read'])]
private int $occurrenceCount = 0;
#[ORM\Version]
#[ORM\Column(type: 'integer', options: ['default' => 1])]
private int $version = 1;
/** @var Collection<int, Task> */
#[ORM\OneToMany(targetEntity: Task::class, mappedBy: 'recurrence')]
private Collection $tasks;
@@ -187,25 +190,9 @@ class TaskRecurrence
$this->tasks = new ArrayCollection();
}
public function getId(): ?int { return $this->id; }
public function getType(): RecurrenceType { return $this->type; }
public function setType(RecurrenceType $type): static { $this->type = $type; return $this; }
public function getInterval(): int { return $this->interval; }
public function setInterval(int $interval): static { $this->interval = $interval; return $this; }
public function getDaysOfWeek(): ?array { return $this->daysOfWeek; }
public function setDaysOfWeek(?array $daysOfWeek): static { $this->daysOfWeek = $daysOfWeek; return $this; }
public function getDayOfMonth(): ?int { return $this->dayOfMonth; }
public function setDayOfMonth(?int $dayOfMonth): static { $this->dayOfMonth = $dayOfMonth; return $this; }
public function getWeekOfMonth(): ?int { return $this->weekOfMonth; }
public function setWeekOfMonth(?int $weekOfMonth): static { $this->weekOfMonth = $weekOfMonth; return $this; }
public function getEndDate(): ?\DateTimeImmutable { return $this->endDate; }
public function setEndDate(?\DateTimeImmutable $endDate): static { $this->endDate = $endDate; return $this; }
public function getMaxOccurrences(): ?int { return $this->maxOccurrences; }
public function setMaxOccurrences(?int $maxOccurrences): static { $this->maxOccurrences = $maxOccurrences; return $this; }
public function getOccurrenceCount(): int { return $this->occurrenceCount; }
public function incrementOccurrenceCount(): static { $this->occurrenceCount++; return $this; }
/** @return Collection<int, Task> */
public function getTasks(): Collection { return $this->tasks; }
// Standard multi-line getters/setters following codebase convention (PHP CS Fixer).
// All properties get standard get/set methods.
// Special methods: incrementOccurrenceCount() and getVersion()
}
```
@@ -722,38 +709,44 @@ final readonly class TaskCalendarProcessor implements ProcessorInterface
}
```
- [ ] **Step 2: Update Task entity** — change `Post` processor and `Patch`/`Delete` processors
- [ ] **Step 2: Update Task entity and TaskNumberProcessor**
The `Post` operation needs both task number generation AND calendar sync. Create a composite approach:
- `Post` processor: `TaskNumberProcessor` (which calls persistProcessor internally), then `CalDavService::syncTask()` is called by a wrapping processor, OR chain `TaskCalendarProcessor` to wrap `TaskNumberProcessor`.
Simplest approach: modify `TaskNumberProcessor` to also handle calendar sync after persist, or create `TaskCalendarProcessor` as the outer processor that delegates to `TaskNumberProcessor` for Post.
**Approach chosen:** Keep `TaskNumberProcessor` for Post (inject `CalDavService` into it). Use `TaskCalendarProcessor` for Patch and Delete only.
Update `Task.php` operations:
```php
new Post(security: "is_granted('ROLE_ADMIN')", processor: TaskCalendarProcessor::class),
new Patch(security: "is_granted('ROLE_ADMIN')", processor: TaskCalendarProcessor::class),
new Delete(security: "is_granted('ROLE_ADMIN')", processor: TaskCalendarProcessor::class),
```
For Post, `TaskCalendarProcessor` needs to delegate to `TaskNumberProcessor` instead of `persistProcessor`. Use a tagged service or inject `TaskNumberProcessor` directly for Post operations.
Alternative (simpler): keep `TaskNumberProcessor` for Post, inject `CalDavService` into it, and call sync after the transaction. Use `TaskCalendarProcessor` only for Patch/Delete.
```php
// In Task.php:
new Post(security: "is_granted('ROLE_ADMIN')", processor: TaskNumberProcessor::class),
new Patch(security: "is_granted('ROLE_ADMIN')", processor: TaskCalendarProcessor::class),
new Delete(security: "is_granted('ROLE_ADMIN')", processor: TaskCalendarProcessor::class),
```
And add CalDAV sync to `TaskNumberProcessor::process()`:
Modify `TaskNumberProcessor`: inject `CalDavService`, after the transaction completes call sync:
```php
// After the persist, sync to calendar
$this->calDavService->syncTask($data);
$this->entityManager->flush();
public function __construct(
// ... existing deps ...
private CalDavService $calDavService,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if ($operation instanceof Post && null !== $data->getProject()) {
$result = $this->entityManager->wrapInTransaction(function () use ($data, $operation, $uriVariables, $context) {
$maxNumber = $this->taskRepository->findMaxNumberByProjectForUpdate($data->getProject());
$data->setNumber($maxNumber + 1);
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
});
// After DB commit: sync to Zimbra
$this->calDavService->syncTask($data);
$this->entityManager->flush();
return $result;
}
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
```
- [ ] **Step 3: Commit**
@@ -772,9 +765,13 @@ git commit -m "feat : add TaskCalendarProcessor for CalDAV sync after DB operati
- [ ] **Step 1: Add recurrence logic to Patch handling**
**CRITICAL: Detect status CHANGE to isFinal, not just current isFinal.**
In `TaskCalendarProcessor`, when processing a Patch:
1. After `persistProcessor->process()`, check if the task's status is `isFinal` AND task has a recurrence
2. If so:
1. **Before** calling `persistProcessor->process()`, store the original status: `$originalStatus = $data->getStatus()`
2. After `persistProcessor->process()`, check if the status **changed** to `isFinal` (original status was NOT isFinal, new status IS isFinal) AND task has a recurrence
3. If the status was already isFinal before the Patch, skip recurrence creation (avoids duplicates on subsequent edits)
4. If conditions met:
- Archive the current task (`archived = true`)
- Clear `calendarEventUid` and `calendarTodoUid` on archived task
- Use `RecurrenceCalculator::getNextDate()` to compute next dates
@@ -783,6 +780,7 @@ In `TaskCalendarProcessor`, when processing a Patch:
- Generate new task number via `TaskRepository::findMaxNumberByProjectForUpdate()`
- Set first status (lowest position, non-final)
- Set recalculated scheduledStart, scheduledEnd, deadline
- **Do NOT copy** documents or bookStackLinks (those are specific to the completed task instance)
- Copy `calendarEventUid` from the archived task (before clearing) to the new task
- Create new VTODO for the new deadline
- Increment `occurrenceCount` on `TaskRecurrence`
@@ -812,6 +810,8 @@ git commit -m "feat : auto-create next recurring task when current task reaches
- [ ] **Step 2: Update `UpdateTaskTool`** — same new params. After flush, call `$this->calDavService->syncTask($task)` and flush. Include calendar fields in JSON response.
- [ ] **Step 2b: Update `DeleteTaskTool`** — inject `CalDavService`. Before removing the task, store `calendarEventUid` and `calendarTodoUid`. After `$entityManager->flush()`, call `$this->calDavService->deleteEvent($eventUid)` and `$this->calDavService->deleteTodo($todoUid)` if non-null. **This is critical — MCP delete bypasses API Platform processors.**
- [ ] **Step 3: Create `CreateTaskRecurrenceTool`**
```php
@@ -1245,15 +1245,16 @@ const form = reactive({
recurrenceEndDate: '' as string,
})
const weekDays = [
{ value: 'monday', label: 'Lu' },
{ value: 'tuesday', label: 'Ma' },
{ value: 'wednesday', label: 'Me' },
{ value: 'thursday', label: 'Je' },
{ value: 'friday', label: 'Ve' },
{ value: 'saturday', label: 'Sa' },
{ value: 'sunday', label: 'Di' },
]
const { t } = useI18n()
const weekDays = computed(() => [
{ value: 'monday', label: t('tasks.planning.days.mon') },
{ value: 'tuesday', label: t('tasks.planning.days.tue') },
{ value: 'wednesday', label: t('tasks.planning.days.wed') },
{ value: 'thursday', label: t('tasks.planning.days.thu') },
{ value: 'friday', label: t('tasks.planning.days.fri') },
{ value: 'saturday', label: t('tasks.planning.days.sat') },
{ value: 'sunday', label: t('tasks.planning.days.sun') },
])
function toggleDay(day: string) {
const idx = form.recurrenceDaysOfWeek.indexOf(day)
@@ -1336,6 +1337,24 @@ git commit -m "feat(ui) : add deadline badges and calendar/recurrence icons to t
---
## Task 15b: Update "Mes tâches" Page
**Files:**
- Modify: `frontend/pages/my-tasks.vue`
- [ ] **Step 1: Add deadline and scheduled date columns/badges** — same pattern as TaskListItem/TaskCard updates from Task 15.
- [ ] **Step 2: Add sort options** — allow sorting by `deadline` and `scheduledStart` (use API Platform OrderFilter added in Task 5). Add sort dropdown or clickable column headers.
- [ ] **Step 3: Commit**
```bash
git add frontend/pages/my-tasks.vue
git commit -m "feat(ui) : add deadline/scheduled columns and sort options to Mes tâches page"
```
---
## Task 16: Create Admin Zimbra Tab
**Files:**