Files
nextcloud-workflow-deck-aut…/CLAUDE.md
T
Patrick Niebeling ed684cca4f Translate CLAUDE.md to German and condense it
Drops history and duplicated explanations, fixes the stale branch
description (nextcloud-35 was merged into main; the 34.x line only
survives as tags) and the miscounted frontend gotchas.
2026-09-30 10:22:52 +02:00

15 KiB
Raw Blame History

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-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() ist bewusst static und seiteneffektfrei → unit-testbar (tests/Unit/Service/WorkflowRunnerTest.php), der einzige getestete Teil 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 notifyEmail (das betrifft verschobene Karten). 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“.

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:

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-Adresse, 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).

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.
  • NcSelects 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.