Replaces the yes/no notify_email flag with notify_mode:
- off: no mail
- owner: only the board owner, as before (migrated from notify_email=1)
- board: everyone with access to the board, filtered by their own Deck
"Notifications" setting for it (board:<id>:notify-due: off /
assigned / all), mirroring Deck's NotificationHelper rules
Recipients are resolved via PermissionService::findUsers() (owner, ACL,
groups and circles expanded), AssignmentMapper::isUserAssigned() and
IConfig, none of which reads Deck's frozen $userId. If they cannot be
resolved, no mail is sent rather than one to the wrong people.
Moved cards are collected over the whole run and each recipient gets a
single digest mail afterwards, in their own language.
Bumps to 35.2.0 so the migrations run.
154 lines
17 KiB
Markdown
154 lines
17 KiB
Markdown
# CLAUDE.md
|
||
|
||
Leitfaden für Claude Code (claude.ai/code) in diesem Repository.
|
||
|
||
## Worum es geht
|
||
|
||
Nextcloud-App `workflow_deck_automation` (Namespace `WorkflowDeckAutomation`) für **Nextcloud 35** (`main`, `min-version="35" max-version="35"`). Jeder Nutzer legt in seinen persönlichen Einstellungen „Workflows“ an, die überfällige [Deck](https://github.com/nextcloud/deck)-Karten von einem Stack in einen anderen verschieben, optional mit Mail. Es gibt bewusst **keine Kartenfilter**: ein Workflow verschiebt *jede* überfällige Karte seines Quell-Stacks. Ein Hintergrund-`Job` wertet alle aktiven Workflows bei jedem Cron-Lauf aus.
|
||
|
||
## Harte Vorgabe: keine HTTP/OCS-Aufrufe gegen Deck
|
||
|
||
Alle Lesezugriffe (Boards, Stacks, Karten) und das Verschieben laufen über Decks interne PHP-Klassen (`OCA\Deck\Service\*`, `OCA\Deck\Db\*`), in-process per `\OCP\Server::get()`. Das ist eine ausdrückliche, wiederholte Anforderung des Nutzers – nicht auf `IClientService`/`curl` gegen `/ocs/...` „umbauen“.
|
||
|
||
**Nur `lib/Service/DeckIntegrationService.php` referenziert `OCA\Deck\*`.** Neue Deck-Daten → diese Klasse erweitern. Jeder Aufruf dort ist abgesichert (`class_exists()`, `IAppManager::isEnabledForUser('deck', …)`, try/catch → `DeckUnavailableException`), weil `OCA\Deck\*` keine stabile, dokumentierte API ist (keine `@since`-Marker).
|
||
|
||
## Impersonation im Hintergrundjob – Decks eingefrorene `$userId`
|
||
|
||
`WorkflowRunner` gruppiert Workflows nach Owner und impersoniert jeden nacheinander, in zwei Schritten (beide im `finally` via `clearImpersonation()` zurückgesetzt):
|
||
|
||
1. `IUserSession::setUser()` + `IRootFolder::getUserFolder()` – für die wenigen Dienste, die die Session lesen. Nebensächlich.
|
||
2. `DeckIntegrationService::beginUserContext()` – **der tragende Teil.**
|
||
|
||
**Deck liest für Berechtigungen nie die Session.** `PermissionService` (hinter jeder Prüfung, auch in `CardService::reorder()`), `CardService`, `BoardService` und `ActivityManager` halten `private ?string $userId` aus dem `userId`-Service des Deck-Containers – *shared*, also einmal pro Prozess aufgelöst. Im Cron ist das nie der impersonierte Nutzer, sondern `null` (wenn vorher ein Deck-Job lief) oder der erste Owner. `null` lässt jede Prüfung mit `NoPermissionException` scheitern.
|
||
|
||
`beginUserContext()` überschreibt daher das private Feld per Reflection und `endUserContext()` stellt es wieder her. `PermissionService` ist Pflicht: klappt das Pinnen nicht, wirft die Methode und der Runner **überspringt den Nutzer komplett**. Die anderen drei sind best effort (nur Activity-Zuordnung/Unread-Zähler).
|
||
|
||
**Regel:** Bevor ein weiterer `OCA\Deck\*`-Dienst im Runner genutzt wird, prüfen, ob er eine eingefrorene `$userId` trägt – der Fehler ist still und nutzerübergreifend. Bevorzugt Mapper (QBMapper ohne Nutzerzustand) oder Methoden mit explizitem uid-Parameter verwenden. Deshalb wird `BoardService::getUserBoards()` nicht verwendet.
|
||
|
||
Die Fälligkeitslogik `WorkflowRunner::isOverdue()` und die Empfängerregel `NotificationPolicy::shouldNotify()` sind bewusst `static` und seiteneffektfrei, also ohne Deck unit-testbar (`tests/Unit/Service/`). Das sind die einzigen getesteten Teile der App. Neue reine Logik in dieser Form halten.
|
||
|
||
## Workflows werden deaktiviert, wenn Board/Stack verschwinden
|
||
|
||
`WorkflowRunner::findBrokenTarget()` prüft vor jedem Lauf Board und Stacks; fehlt etwas, wird der Workflow abgeschaltet (`enabled = false`) und einmalig gemailt. Invarianten:
|
||
|
||
- **Nur positives Wissen deaktiviert.** `findStackIds()` liefert `null` nur bei „Board-Zeile weg“ (`DoesNotExistException`, Decks `NotFoundException`), „Board im Papierkorb“ (`deleted_at > 0`) oder „uid hat keine Leserechte“ – alles andere *wirft*, und der Runner überspringt den Workflow. Degradiert die Methode auf `[]`/`null`, deaktiviert ein Deck-Ausfall alle Workflows der Instanz und mailt alle Nutzer an.
|
||
- **`findStackIds()` fragt nie einen Deck-Dienst, *wer* fragt.** Sie bekommt die uid als Argument und nutzt nur `BoardMapper`/`StackMapper` plus `PermissionService::getPermissions($boardId, $userId)` mit expliziter uid. Nicht zu `StackService::findAll($boardId)` „vereinfachen“ – genau das prüfte gegen die eingefrorene `null`-uid, warf `NoPermissionException`, wurde als „Board weg“ gelesen und verschickte Deaktivierungsmails für intakte Boards.
|
||
- **`NoPermissionException` beweist nichts** und steht deshalb bewusst nicht in `isMissing()`.
|
||
- **Die Deaktivierungsmail ignoriert `notifyMode`** (das betrifft verschobene Karten) und geht immer nur an den Owner. 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.
|
||
|
||
## 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.
|
||
- **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.
|
||
|
||
`user_id` heißt also nur „als wer läuft der Job / wer bekommt Mail“.
|
||
|
||
## Mails über verschobene Karten folgen Decks Benachrichtigungseinstellung
|
||
|
||
`notify_mode` (`NotificationPolicy::MODES`) ersetzt seit `Version1002…`/`Version1003…` das frühere `notify_email` (`true` → `owner`):
|
||
|
||
- `off`: keine Mail. `owner`: nur der Board-Owner, ohne Rücksicht auf seine Deck-Einstellung (altes Verhalten).
|
||
- `board`: alle Board-Berechtigten, gefiltert nach deren Deck-Nutzereinstellung `deck` / `board:<id>:notify-due` (`off`/`assigned`/`all`, Default `assigned`). `NotificationPolicy::shouldNotify()` bildet Decks Regel aus `NotificationHelper::sendCardDuedate()` nach und ist reine, getestete Logik. Dazu gehört: Der Owner eines ungeteilten Boards wird außer bei `off` immer benachrichtigt.
|
||
- `DeckIntegrationService::findNotificationRecipients()` nutzt nur uid-explizite Bausteine: `PermissionService::findUsers($boardId, true)` (Owner + ACL, löst Gruppen/Circles auf, `true` umgeht den Prozess-Cache), `AssignmentMapper::isUserAssigned()` (auch Gruppen-/Circle-Zuweisungen) und `IConfig`. `FederatedUser` wird übersprungen. Bei jedem Fehler wirft die Methode, und der Runner verschickt dann **keine** Mail, statt an eine falsch aufgelöste Empfängerliste zu schicken.
|
||
- Der Runner sammelt verschobene Karten in `$pendingMails` und verschickt **nach** der Nutzer-Schleife (also ohne Impersonation) eine Sammelmail pro Empfänger und Lauf. Die Sprache kommt pro Empfänger aus `IFactory::getUserLanguage()`, weil ein injiziertes `IL10N` im Cron nur die Instanz-Standardsprache kennt.
|
||
|
||
## Fälligkeit wird als UTC-Zeitpunkt verglichen
|
||
|
||
Decks `duedate` ist eine `DATETIME`-Spalte ohne Zeitzone mit UTC-Uhrzeit (Frontend sendet `toISOString()`). `Entity` baut daraus `new \DateTime($string)` in PHPs Standardzone, die Nextcloud auf UTC setzt. `WorkflowRunner::asStoredUtc()` legt die Uhrzeit trotzdem ausdrücklich auf UTC fest, damit das Ergebnis nicht von dieser Standardzone abhängt. Decks eigene Pfade `Card::getDaysUntilDue()` (tagesgenau) und `CardMapper::findOverdue()` (vergleicht mit dem `NOW()` der Datenbank, also Ortszeit) sind dafür ungeeignet und werden nicht verwendet.
|
||
|
||
## Kartenfilter wurden entfernt
|
||
|
||
Die früheren Filter (`filter_user_ids`/`filter_label_ids`) wurden auf Wunsch entfernt. `Version1001Date20260930120000` löscht die Spalten, und gefilterte Workflows laufen seitdem *ungefiltert* weiter (bewusste Entscheidung). Die Spalten müssen gelöscht bleiben: `Entity::fromRow()` ruft pro Spalte einen Setter auf, den `Workflow` nicht mehr hat. Mit entfernt wurden: Labels-/Participants-Endpunkte, `listLabels()`/`listParticipants()`/`findFilterOptions()` und `CardService::enrichCards()` in `getActiveCardsInStack()` (lief über `BoardService::find()` mit eingefrorener uid). Die Karten dort sind also **nicht angereichert** (keine `assignedUsers`/Labels).
|
||
|
||
## Der Job hat kein Intervall
|
||
|
||
`RunWorkflowsJob` erweitert `OCP\BackgroundJob\Job`, **nicht** `TimedJob`: ein Lauf pro Cron-Durchgang, kein Intervall, keine Konfiguration (der frühere `config.php`-Schlüssel `workflow_deck_automation.interval` ist seit v0.2.0 weg). Nicht wieder einführen.
|
||
|
||
**Zeitkritikalität entscheidet, ob der Job überhaupt läuft.** `CronService::runCli()` holt außerhalb des Wartungsfensters (`maintenance_window_start` … +4 h UTC) nur zeitkritische Jobs (`getNext($onlyTimeSensitive = true)`). Eine frühere Version war `TIME_INSENSITIVE` und lief auf Instanzen mit Wartungsfenster nie (`last_run = 1970-01-01`). Manuelle Tests (`background-job:worker '<Klasse>'`, Ajax/Webcron) umgehen dieses Gate, deshalb wirkt dort alles normal.
|
||
|
||
Als einfacher `Job` ist das ausgeschlossen: `JobList::add()` schreibt die Spalte `time_sensitive` nicht (Default `TIME_SENSITIVE`), und nur `JobList::setLastRun()` senkt sie – ausschließlich für `TimedJob && !isTimeSensitive()`, ohne Rückweg. Alte, einmal als insensitive gestempelte Zeilen bleiben es (auch nach App-Update). Einmalig reparieren:
|
||
|
||
```sql
|
||
UPDATE oc_jobs SET time_sensitive = 0
|
||
WHERE class = 'OCA\\WorkflowDeckAutomation\\BackgroundJob\\RunWorkflowsJob';
|
||
```
|
||
|
||
oder `occ background-job:delete <id>`, dann `occ app:disable workflow_deck_automation && occ app:enable workflow_deck_automation`.
|
||
|
||
Debuggen: `occ background-job:list --class '<Klasse>'` (ID, letzter Lauf), dann `occ background-job:execute <id> --force-execute`. `background-job:worker` erwartet **Job-Klassen**, keine App-ID.
|
||
|
||
## Registrierung deklarativ statt über Bootstrap
|
||
|
||
Hintergrundjob und persönliche Einstellungen stehen in `appinfo/info.xml` (`<background-jobs>`, `<settings><personal>/<personal-section>`). `IRegistrationContext` hat dafür keine Methoden. `lib/AppInfo/Application.php` ist absichtlich fast leer.
|
||
|
||
## Architektur
|
||
|
||
- **UI:** `src/PersonalSettings.vue` (Vue 3), gemountet aus `templates/settings/personal.php` (Sektion: `lib/Settings/PersonalSection.php` + `Personal.php`) → `src/api.js` → `lib/Controller/WorkflowController.php` (`OCSController`, Routen in `appinfo/routes.php`, `/ocs/v2.php/apps/workflow_deck_automation/api/v1/...`).
|
||
- **Speicher:** `lib/Db/Workflow.php` / `WorkflowMapper.php`, Tabelle `wfda_workflows` (Migrationen in `lib/Migration/`).
|
||
- **Automation:** `RunWorkflowsJob` → `WorkflowRunner` → `DeckIntegrationService` (alle Deck-Aufrufe) und `NotificationMailer` (`IMailer`, an die Account-Adressen der Empfänger, nie an eine eingegebene Adresse).
|
||
- Controller (Dropdowns) und Runner (Karten lesen/verschieben) nutzen denselben `DeckIntegrationService`.
|
||
|
||
## Befehle und Verifikation
|
||
|
||
**Lokal gibt es kein PHP und kein npm** (Windows, siehe `CLAUDE.local.md`) – `composer`/`php`/`npm` nicht lokal ausführen. Verifiziert wird über Push/PR und den Gitea-Actions-Lauf (Runner `gitea-runner-server03`).
|
||
|
||
```bash
|
||
composer install
|
||
composer run lint # php -l über lib/ und tests/
|
||
composer run cs:check # nextcloud/coding-standard; cs:fix korrigiert
|
||
composer run test:unit # PHPUnit, nur reine Logik
|
||
vendor/bin/phpunit --filter <testName> tests/Unit/Service/WorkflowRunnerTest.php
|
||
|
||
npm install # nicht `npm ci`: Lockfiles sind absichtlich nicht committet
|
||
npm run build # bzw. npm run watch
|
||
```
|
||
|
||
`composer.lock` und `package-lock.json` stehen in `.gitignore` → CI muss `composer install`/`npm install` verwenden (`npm ci` hat den Build schon einmal gebrochen). Die Deck-Integration lässt sich nur auf einer echten Nextcloud+Deck-Instanz testen.
|
||
|
||
## Release und CI
|
||
|
||
**Versionsschema:** `<Nextcloud-Major>.<minor>.<patch>`. Die erste Zahl entspricht `min-/max-version` in `info.xml` und ändert sich nur mit der unterstützten NC-Major (dann `minor.patch` = `0.0`). `0.x`-Versionen stammen aus der Zeit davor. Die 34er-Linie existiert nur noch als Tags (`v34.*`). Kein Branch.
|
||
|
||
1. `<version>` in `appinfo/info.xml` und `version` in `package.json` synchron erhöhen.
|
||
2. Committen, nach `main` pushen, `build-main.yml` muss grün sein.
|
||
3. `git tag vX.Y.Z && git push origin vX.Y.Z` → Checks, Frontend-Build, `make appstore`, Release mit `workflow_deck_automation.tar.gz`.
|
||
|
||
Ist ein Tag-Build fehlgeschlagen und **nie ein Artefakt entstanden**, den Tag auf den Fix verschieben statt die Version zu erhöhen (`git tag -d vX.Y.Z && git tag -a vX.Y.Z -m "..." && git push origin :refs/tags/vX.Y.Z && git push origin vX.Y.Z`). Der Job löscht ein vorhandenes Release vorher. Nach einem veröffentlichten Artefakt den Tag nicht mehr verschieben.
|
||
|
||
**`.gitea/workflows/build-main.yml` ist der einzige Workflow** (Checks `php-lint` 8.2–8.4, `xml-lint`, `unit-tests` + Job `package`), damit `needs:` das Release an grüne Checks koppelt. Nicht wieder aufteilen.
|
||
|
||
- `package` baut *und* veröffentlicht in einem Job und braucht nur Node (`make appstore` ist reines `mkdir`/`tar`/`rm`).
|
||
- Bei `pull_request` wird `Publish release` übersprungen (`if: gitea.event_name == 'push'`) und der Job ist nur ein Build-Check.
|
||
- Push auf `main` → Pre-Release `latest-main`, Release *und* Tag werden jedes Mal neu erzeugt (Download-Link zeigt immer den neuesten Build). `v*`-Tag → normales Release. Dabei wird **nie der Tag** gelöscht (Flag `RECREATE_TAG`).
|
||
- **Kein `actions/upload-artifact`:** Gitea gibt sich als GHES aus, `@actions/artifact` ≥ 2 verweigert dann (`GHESNotSupportedError`). Veröffentlicht wird per `curl` gegen die Gitea-Release-API. `checkout`/`setup-node` sind nicht betroffen.
|
||
- **Kein PAT nötig:** Auth über das automatische `secrets.GITEA_TOKEN` mit `permissions: contents: write`. Bei 403: prüfen, ob die Token-Berechtigung des Repos/der Org auf *Restricted* steht (deckelt `permissions:`). Das ist eine Einstellung, kein Workflow-Bug.
|
||
|
||
## Frontend-Build-Fallen (alle erst in CI aufgefallen)
|
||
|
||
- **`vite` muss zum Peer von `@nextcloud/vite-config` passen** (aktuell `vite@^7.3.6`), sonst `ERESOLVE`. Beim Update von `vite-config` dessen `peerDependencies.vite` prüfen.
|
||
- **`"type": "module"` in `package.json`**, sonst scheitert `vite.config.js` am ESM-only-Paket.
|
||
- **`tsconfig.json` und `typescript` als devDependency sind Pflicht**, obwohl es kein TypeScript gibt: `@nextcloud/vite-config` importiert über sein Barrel `vite-plugin-dts`, das `typescript` als Peer braucht. Ohne das Paket crasht es beim Laden (`Cannot read properties of undefined (reading 'useCaseSensitiveFileNames')`). `typescript` nahe an der Version von `vite-config` halten (aktuell `^5.9.3`).
|
||
- **`createAppConfig` stellt die App-ID vor jeden Entry-Namen:** `personal-settings` → `js/workflow_deck_automation-personal-settings.mjs`. `Util::addScript()`/`addStyle()` in `lib/Settings/Personal.php` brauchen diesen vollen Namen (`Application::APP_ID . '-personal-settings'`). Falsch → **leere Einstellungsseite**, nur `Could not find resource …` im Log. Umbenennungen in `vite.config.js` auch dort nachziehen.
|
||
- Die eigentliche CSS steckt in einem gehashten `*.chunk.css`. Die Entry-CSS ist ein ~100-Byte-Stub (`createEmptyCSSEntryPoints`). Das ist korrekt, also Code-Splitting nicht abschalten.
|
||
|
||
## `@nextcloud/vue` 9 (Vue 3) – v8-Idiome scheitern still
|
||
|
||
- **Kein `.sync`:** `NcTextField`/`NcCheckboxRadioSwitch` → `v-model`. `:value.sync` lässt den Wert auf dem Startwert stehen.
|
||
- **`NcSelect`: `label` ist der Anzeige-Schlüssel der Optionen** (vue-select), die Beschriftung ist `input-label`. `label="Board"` rendert überall `undefined`.
|
||
- **`NcButton`: `variant` = Stil, `type` = nativer Button-Typ**, `native-type` gibt es nicht mehr. Ein übrig gebliebenes `type="primary"` wird zu `submit`, und Abbrechen schickt das Formular ab.
|
||
- `NcSelect`s `reduce` erreicht vue-select nur per Attribut-Fallthrough (funktioniert, ist aber undokumentiert).
|
||
- `NcNoteCard` nutzt weiterhin `type` (`success|info|warning|error`).
|
||
|
||
## Deck-Interna, die man kennen muss
|
||
|
||
- **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.
|
||
- Der Deck-Quellcode liegt lokal unter `C:\Users\pani\Documents\git-privat\nextcloud-deck` und dient zum Nachprüfen von Interna.
|