diff --git a/CLAUDE.md b/CLAUDE.md index eb6d054..a746ffd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,13 +36,13 @@ Der Datumsvergleich `WorkflowRunner::hasPassed()`, `TriggerDate`, `LoopDetector` - **`NoPermissionException` beweist nichts** und steht deshalb bewusst nicht in `isMissing()`. - **Die Deaktivierungsmail ignoriert `notifyMode` und `notifyChannel`** (die betreffen verschobene Karten) und geht immer nur an den Owner, zusätzlich als Nextcloud-Benachrichtigung, falls die Notifications-App aktiv ist. Sie bleibt einmalig, weil `findAllEnabled()` deaktivierte Workflows nicht mehr lädt. -Archivierte Boards lösen das nicht aus (sie bleiben lesbar), werden aber in den Dropdowns ausgeblendet. +Archivierte Boards lösen das nicht aus (sie bleiben lesbar). Ihre Workflows bleiben aktiv, werden aber übersprungen: `runWorkflow()` fragt nach `findBrokenTarget()` `DeckIntegrationService::isBoardArchived()` (`BoardMapper`, `false` bei fehlendem Board, sonst wirft es). Im Dropdown erscheinen archivierte Boards nie, auch nicht beim Bearbeiten (dann leert das Formular Board und Stapel und zeigt einen Hinweis). In der Übersicht werden sie markiert. ## Ein Workflow gehört dem Board-Owner, nicht seinem Ersteller Wird ein Board in Deck übertragen, wandern seine Workflows mit. -- **„Meine Boards“** = `listOwnedBoards($uid)` → `BoardMapper::findAllByOwner($uid)` (keine geteilten Boards, keine eingefrorene uid). Diese Mapper-Methode filtert weder Papierkorb noch Archiv: Papierkorb-Boards werden verworfen, archivierte mit `archived: true` behalten (Workflows sichtbar, aber nicht im Dropdown, von `validate()` abgelehnt). Wirft statt `[]` zurückzugeben – `[]` würde alle Workflows des Nutzers verstecken. +- **„Meine Boards“** = `listOwnedBoards($uid)` → `BoardMapper::findAllByOwner($uid)` (keine geteilten Boards, keine eingefrorene uid). Diese Mapper-Methode filtert weder Papierkorb noch Archiv: Papierkorb-Boards werden verworfen, archivierte mit `archived: true` behalten (Workflows sichtbar, aber nicht im Dropdown, von `validate()` abgelehnt, vom Runner übersprungen). Wirft statt `[]` zurückzugeben – `[]` würde alle Workflows des Nutzers verstecken. - **Auflisten/Bearbeiten/Löschen** nur über `board_id IN (listOwnedBoards)` (`WorkflowMapper::findAllForBoards()`, `WorkflowController::findOwnWorkflow()`). `user_id` spielt keine Rolle – keine `user_id`-Abfragen im Controller wieder einführen. - **Anlegen/Speichern** validiert gegen dieselbe Liste und setzt `user_id` auf den aktuellen Nutzer. - **Runner:** `followBoardOwner()` → `findBoardOwner()` (`BoardMapper::find()`) schreibt einen abweichenden Owner in `user_id` zurück. Lauf und Mails gehen also an den aktuellen Owner. Ein verschwundenes Board behält den letzten Owner, damit `findBrokenTarget()` ihn anmailen kann. @@ -182,4 +182,5 @@ Ist ein Tag-Build fehlgeschlagen und **nie ein Artefakt entstanden**, den Tag au - **Relationale Entities:** Auf angereicherten Entities liefern `$acl->getParticipant()`, `$board->getOwner()` oder Assignment-Teilnehmer ein `RelationalObject` statt einer uid (uid = `getPrimaryKey()`). Neue Teilnehmer-/Owner-Felder immer über `DeckIntegrationService::unwrapUid()` laufen lassen. - Die ACL eines Boards enthält **nicht** den Owner (privates Board = leere ACL). ACL-Einträge können Gruppen (`type === 1`) oder Circles sein. - `StackMapper::findAll()` filtert `deleted_at = 0` selbst. Stacks haben keinen Archivstatus. +- **Entity-Getter sind magisch** (`Entity::__call`, z. B. `getDeletedAt()`, `getArchived()`, `getStartdate()`). `method_exists()` ist für sie immer `false`. Vorhandensein per `property_exists()` prüfen (`DeckIntegrationService::isTrashed()`/`isArchived()`/`supportsStartdate()`). Mit `method_exists()` wurden früher Karten aus dem Papierkorb verschoben, und Papierkorb- wie Archiv-Boards blieben unerkannt. - Der Deck-Quellcode liegt lokal unter `C:\Users\pani\Documents\git-privat\nextcloud-deck` und dient zum Nachprüfen von Interna. diff --git a/README.md b/README.md index 86a2a70..975da4b 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ Deck schickt zusätzlich seine eigene Erinnerung, wenn eine Karte fällig wird. - **In der Übersicht** wird der Workflow rot markiert, zum Beispiel „Board nicht mehr vorhanden“ oder „Quell-Stapel nicht mehr vorhanden“. - **Beim nächsten Durchlauf** wird der Workflow automatisch deaktiviert. Der Eigentümer bekommt einmalig eine E-Mail und, wenn die App *Benachrichtigungen* aktiv ist, eine Nextcloud-Benachrichtigung. Das gilt unabhängig davon, was für verschobene Karten eingestellt ist. Danach kommt keine weitere Erinnerung. - **Schleifen aus älteren Workflows** (gespeichert vor der Schleifenprüfung) werden in der Übersicht rot markiert und beim Durchlauf übersprungen, bis du einen der Workflows änderst oder deaktivierst. -- **Archivierte Boards** deaktivieren den Workflow nicht. Sie lassen sich nur nicht mehr neu auswählen. +- **Archivierte Boards** deaktivieren den Workflow nicht. Er bleibt gespeichert, wird aber in der Übersicht markiert („Board archiviert, wird übersprungen“) und verschiebt nichts, bis du das Board in Deck aus dem Archiv holst. Archivierte Boards lassen sich auch nicht auswählen. Beim Bearbeiten eines solchen Workflows musst du ein anderes Board wählen. - **Ist Deck vorübergehend nicht erreichbar**, passiert nichts: Der Workflow wird übersprungen und beim nächsten Durchlauf erneut versucht. Um einen deaktivierten Workflow wieder zu nutzen, stellst du ein vorhandenes Board und vorhandene Stapel ein und aktivierst ihn wieder. diff --git a/lib/Service/DeckIntegrationService.php b/lib/Service/DeckIntegrationService.php index 6460d0b..c0915fd 100644 --- a/lib/Service/DeckIntegrationService.php +++ b/lib/Service/DeckIntegrationService.php @@ -172,6 +172,26 @@ class DeckIntegrationService { return class_exists(Card::class) && property_exists(Card::class, 'startdate'); } + /** + * Whether a Deck board or card sits in Deck's trash (`deleted_at > 0`). + * + * Read through property_exists(), never method_exists(): Entity getters + * are magic (`__call`), so method_exists() is always false for them — + * which is how trashed cards used to be moved and trashed boards went + * unnoticed. + */ + private static function isTrashed(object $entity): bool { + return property_exists($entity, 'deletedAt') && (int)$entity->getDeletedAt() > 0; + } + + /** + * Whether a Deck board is archived. Same magic-getter caveat as + * isTrashed(). + */ + private static function isArchived(object $entity): bool { + return property_exists($entity, 'archived') && (bool)$entity->getArchived(); + } + /** * The boards `$userId` owns — the only boards workflows may live on, and * the boards whose workflows that user sees and manages. @@ -181,7 +201,8 @@ class DeckIntegrationService { * the frozen-uid state of BoardService::getUserBoards(). It does return * archived and trashed boards; trashed ones are dropped here, archived * ones are kept but flagged — their workflows still exist and must stay - * visible, they just aren't offered as a new target. + * visible, but the board is no target for a new or changed workflow, and + * the runner skips it. * * Throws instead of degrading to `[]`: an empty list would read as "you * own no boards" and hide every workflow the user has. @@ -198,7 +219,7 @@ class DeckIntegrationService { $result = []; foreach ($boards as $board) { - if (method_exists($board, 'getDeletedAt') && (int)$board->getDeletedAt() > 0) { + if (self::isTrashed($board)) { continue; } $id = (int)$board->getId(); @@ -206,7 +227,7 @@ class DeckIntegrationService { $result[] = [ 'id' => $id, 'title' => (is_string($title) && $title !== '') ? $title : ('#' . $id), - 'archived' => method_exists($board, 'getArchived') && (bool)$board->getArchived(), + 'archived' => self::isArchived($board), ]; } @@ -253,7 +274,7 @@ class DeckIntegrationService { public function findStackIds(int $boardId, string $userId): ?array { try { $board = $this->resolve(BoardMapper::class)->find($boardId); - if (method_exists($board, 'getDeletedAt') && (int)$board->getDeletedAt() > 0) { + if (self::isTrashed($board)) { return null; } @@ -283,6 +304,31 @@ class DeckIntegrationService { return array_map(static fn ($stack) => (int)$stack->getId(), $stacks); } + /** + * Whether the board is archived — workflows on archived boards stay + * stored and enabled but move nothing until the board is unarchived. + * + * Read through BoardMapper like findBoardOwner(). `false` if the board + * row is gone, so findBrokenTarget() still gets to disable the workflow; + * every other failure throws. + * + * @throws DeckUnavailableException + */ + public function isBoardArchived(int $boardId): bool { + try { + $board = $this->resolve(BoardMapper::class)->find($boardId); + } catch (DeckUnavailableException $e) { + throw $e; + } catch (Throwable $e) { + if ($this->isMissing($e)) { + return false; + } + throw new DeckUnavailableException('Could not read board ' . $boardId . ': ' . $e->getMessage(), 0, $e); + } + + return self::isArchived($board); + } + /** * The uid of the board's current owner — the user a workflow on that * board belongs to. Workflows follow the board: when Deck transfers its @@ -462,7 +508,7 @@ class DeckIntegrationService { $cards = $this->resolve(CardMapper::class)->findAllByStack($stackId); return array_values(array_filter($cards, static function (Card $card) { - if (method_exists($card, 'getDeletedAt') && (int)$card->getDeletedAt() > 0) { + if (self::isTrashed($card)) { return false; } return !$card->getArchived() && $card->getDone() === null; diff --git a/lib/Service/WorkflowRunner.php b/lib/Service/WorkflowRunner.php index 1c14696..afb1139 100644 --- a/lib/Service/WorkflowRunner.php +++ b/lib/Service/WorkflowRunner.php @@ -229,6 +229,25 @@ class WorkflowRunner { return; } + // An archived board is intact, just shelved: the workflow is skipped, + // not disabled, and resumes once the board is unarchived. + try { + $archived = $this->deckService->isBoardArchived($workflow->getBoardId()); + } catch (DeckUnavailableException $e) { + $this->logger->warning('Could not check whether the board of workflow {id} is archived: ' . $e->getMessage(), [ + 'app' => 'workflow_deck_automation', + 'id' => $workflow->getId(), + ]); + return; + } + if ($archived) { + $this->logger->debug('Skipping workflow {id}: its board is archived', [ + 'app' => 'workflow_deck_automation', + 'id' => $workflow->getId(), + ]); + return; + } + try { $cards = $this->deckService->getActiveCardsInStack($workflow->getSourceStackId()); } catch (DeckUnavailableException $e) { diff --git a/src/PersonalSettings.vue b/src/PersonalSettings.vue index 7090f1e..7985c4a 100644 --- a/src/PersonalSettings.vue +++ b/src/PersonalSettings.vue @@ -70,6 +70,9 @@ :reduce="option => option.value" placeholder="Board wählen" @update:model-value="onBoardChange" /> + + {{ archivedBoardNote }} + Workflows können nur für Boards angelegt werden, deren Eigentümer du bist. @@ -205,6 +208,8 @@ const showForm = ref(false) const saving = ref(false) const formError = ref('') const editingId = ref(null) +// Shown when the workflow being edited sits on an archived board. +const archivedBoardNote = ref('') const stackOptions = ref([]) // Which board the list above currently belongs to. @@ -213,9 +218,10 @@ const loadedBoardId = ref(null) const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }) // The backend only returns boards the user owns. Archived ones are in there -// so their workflows keep a board name, but are no target for new ones. +// so their workflows keep a board name, but are never offered — not even +// when editing a workflow that sits on one, see editWorkflow(). const boardOptions = computed(() => boards.value - .filter((board) => !board.archived || board.id === form.boardId) + .filter((board) => !board.archived) .map((board) => ({ value: board.id, label: board.title })) .sort((a, b) => collator.compare(a.label, b.label))) @@ -254,15 +260,17 @@ const sourceStackOptions = computed(() => stackOptions.value.filter((option) => const targetStackOptions = computed(() => stackOptions.value.filter((option) => option.value !== form.sourceStackId)) /** - * Workflows whose board or stacks were deleted in Deck, keyed by id. The - * background job switches these off by itself, but a row that just stopped + * Workflows that do not run as configured, keyed by id: board or stacks + * deleted in Deck (the background job switches these off by itself), board + * archived, or part of a loop (both skipped). A row that just stopped * working needs to say so instead of looking healthy. */ const workflowIssues = computed(() => { const issues = {} for (const workflow of workflows.value) { - if (!boards.value.some((board) => board.id === workflow.boardId)) { + const board = boards.value.find((entry) => entry.id === workflow.boardId) + if (!board) { issues[workflow.id] = 'Board nicht mehr vorhanden' continue } @@ -282,6 +290,13 @@ const workflowIssues = computed(() => { } } + // Not disabled, just skipped by the background job until the board + // is taken out of the archive again. + if (board.archived) { + issues[workflow.id] = 'Board archiviert, wird übersprungen' + continue + } + // Set by the server for workflows stored before loops were refused; // the background job skips them. if (workflow.loop) { @@ -419,6 +434,9 @@ async function onBoardChange(boardId) { form.sourceStackId = null form.targetStackId = null + if (boardId) { + archivedBoardNote.value = '' + } await loadBoardOptions(boardId) } @@ -427,16 +445,24 @@ function startCreate() { Object.assign(form, emptyForm()) editingId.value = null formError.value = '' + archivedBoardNote.value = '' loadBoardOptions(null) showForm.value = true } async function editWorkflow(workflow) { + // An archived board is not offered and would be refused on save, so the + // form starts without it rather than holding an id no option matches. + const archivedBoard = boards.value.find((board) => board.id === workflow.boardId && board.archived) + archivedBoardNote.value = archivedBoard + ? `Das Board „${archivedBoard.title}“ ist archiviert. Wähle ein anderes Board oder hole es in Deck aus dem Archiv.` + : '' + Object.assign(form, { title: workflow.title, - boardId: workflow.boardId, - sourceStackId: workflow.sourceStackId, - targetStackId: workflow.targetStackId, + boardId: archivedBoard ? null : workflow.boardId, + sourceStackId: archivedBoard ? null : workflow.sourceStackId, + targetStackId: archivedBoard ? null : workflow.targetStackId, triggerDate: workflow.triggerDate || 'due', notifyMode: workflow.notifyMode, notifyChannel: workflow.notifyChannel || 'mail', @@ -445,7 +471,7 @@ async function editWorkflow(workflow) { editingId.value = workflow.id formError.value = '' showForm.value = true - await loadBoardOptions(workflow.boardId) + await loadBoardOptions(form.boardId) } function cancelForm() {