Files
nextcloud-workflow-deck-aut…/CLAUDE.md
T
Patrick Niebeling 8268f1068d
Build package / xml-lint (push) Successful in 3m32s
Build package / unit-tests (push) Successful in 4m49s
Build package / php-lint (8.5) (push) Successful in 4m52s
Build package / php-lint (8.4) (push) Successful in 4m56s
Build package / package (push) Successful in 2m28s
Let workflows move cards by start date instead of due date
Each workflow now has a trigger date (due or start): the runner compares
either the card's due date or its start date against now, read straight
from Deck's Card entity. Mails and Nextcloud notifications say whether
the card was overdue or reached its start date. Cards without the chosen
date stay put.

Deck only has a start date since 1.18 and Entity getters are magic, so
DeckIntegrationService::supportsStartdate() checks the property; without
it the form rejects start-date workflows and the runner skips them
without disabling them.

WorkflowRunner::isOverdue() becomes hasPassed(), as it now covers both
dates. Existing workflows keep the due date (migration Version1005).
Bumps to 35.4.0 so the migration runs.
2026-10-01 13:19:32 +02:00

22 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 Deck-Karten von einem Stack in einen anderen verschieben, sobald ihr Fälligkeits- oder Startdatum erreicht ist (trigger_date, pro Workflow), optional mit Mail. Es gibt bewusst keine Kartenfilter: ein Workflow verschiebt jede Karte seines Quell-Stacks, deren Datum erreicht ist. 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.

Der Datumsvergleich WorkflowRunner::hasPassed(), TriggerDate 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 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.

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 $pendingNotices und verschickt nach der Nutzer-Schleife (also ohne Impersonation) je nach notify_channel des Workflows eine Sammelmail pro Empfänger und Lauf und/oder eine Nextcloud-Benachrichtigung pro Karte.

Nextcloud-Benachrichtigungen sind ein zweiter Kanal, keine zweite Empfängerregel

notify_channel (NotificationPolicy::CHANNELS: mail/notification/both, Default und Migrationswert mail, Version1004…) sagt nur, wie die über notify_mode ermittelten Empfänger informiert werden. NotificationPolicy::sendsMail()/sendsNotification() sind reine, getestete Logik. Unbekannte Werte zählen als mail.

  • NotificationPusher legt über OCP\Notification\IManager eine Benachrichtigung pro Karte an (Objekt card/Karten-ID, Subject card_moved) und räumt vorher per markProcessed() eine ältere zur selben Karte weg. Gespeichert werden nur Rohwerte (Titel, IDs), keine Texte.
  • Notification\Notifier (per registerNotifierService() in Application::register()) baut daraus deutsche Texte und ignoriert $languageCode. Titel sind Nutzereingaben und gehen nur als Rich-Objekte (deck-card, deck-board, highlight) in den Text, nie ins Template, sonst würde ein {…} im Titel als Platzhalter gelesen. Der Notifier fragt Deck nicht: Er läuft beim Abruf durch irgendeinen Nutzer, und Deck-Dienste hätten dort die eingefrorene uid.
  • Ohne Notifications-App passiert nichts: NotificationPusher::isAvailableFor() prüft IAppManager::isEnabledForUser('notifications', $user) pro Empfänger und kehrt stumm zurück. Kein Fallback auf Mail, kein Fehler-Log. Die Einstellungsseite bekommt denselben Check als data-notifications-available und zeigt dann einen Hinweis.
  • Push aufs Handy/Desktop ist Sache der Notifications-App (Push-Proxy bzw. notify_push), nicht dieser App.

Nur Deutsch, keine Übersetzungen

Die App wird nur intern genutzt und spricht ausschließlich Deutsch. Oberfläche, Controller-Fehlermeldungen (erscheinen im Formular), Sektionsname, Mails und Nextcloud-Benachrichtigungen sind direkt auf Deutsch geschrieben. Es gibt kein IL10N und keinen l10n/-Ordner. Nextclouds eigene Mail-Fußzeile wird per addFooter('', 'de') ebenfalls deutsch, sonst würde sie im Cron der Instanz-Standardsprache folgen. Log-Meldungen bleiben englisch. Neue nutzersichtbare Texte auf Deutsch schreiben, mit „du“ wie im Rest der App.

Fälligkeits- oder Startdatum löst das Verschieben aus

trigger_date (TriggerDate::ALL: due/start, Default und Migrationswert due, Version1005…) wählt pro Workflow, welches Kartendatum der Runner mit „jetzt“ vergleicht. Unbekannte Werte zählen als due. Karten ohne das gewählte Datum bleiben liegen.

  • startdate gibt es erst seit Deck 1.18. Card-Getter sind magisch (Entity::__call), method_exists() sieht sie nicht und getStartdate() würde erst beim Aufruf werfen. Daher DeckIntegrationService::supportsStartdate() (property_exists). Der Runner liest getStartdate() nur bei start und überspringt solche Workflows ohne Deaktivierung, wenn Deck das Feld nicht kennt. Der Controller lehnt start dann ab.
  • Texte: TriggerDate::describeReason() liefert den Satzteil („ist überfällig“ / „hat ihr Startdatum erreicht“) für Mail und Notifier. NotificationPusher speichert triggerDate in den Subject-Parametern. Ältere Benachrichtigungen ohne den Schlüssel gelten als due.
  • Empfänger: Auch Startdatums-Workflows nutzen bei notify_mode = board Decks notify-due-Einstellung. Deck hat keine eigene für das Startdatum.

Fälligkeit wird als UTC-Zeitpunkt verglichen

Decks duedate und startdate sind DATETIME-Spalten 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:

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 und registriert nur den Notifier (dafür gibt es keinen info.xml-Eintrag).

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), NotificationMailer (IMailer, an die Account-Adressen der Empfänger, nie an eine eingegebene Adresse) und NotificationPusher (IManager, Texte aus lib/Notification/Notifier.php).
  • Controller (Dropdowns) und Runner (Karten lesen/verschieben) nutzen denselben DeckIntegrationService.
  • Doku: README.md richtet sich an Anwender und Admins und enthält keine Klassennamen. DEVELOPMENT.md ist für Entwickler (Aufbau, Datenmodell, Tests, CI, Release, Fehlersuche). Neue Nutzer-Funktionen in der README beschreiben, technische Details in DEVELOPMENT.md. Beide Dateien sind per Makefile vom Paket ausgeschlossen.

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 Build-/Release-Workflow (Checks php-lint mit PHP 8.4 und 8.5, xml-lint, unit-tests + Job package), damit needs: das Release an grüne Checks koppelt. Nicht wieder aufteilen.

.gitea/workflows/appstore.yml ist nur manuell (workflow_dispatch) und soll so bleiben. Er baut nicht selbst, sondern signiert den Tarball eines vorhandenen v*-Release und meldet dessen Gitea-Download-URL beim App Store an (Secrets APPSTORE_PRIVATE_KEY, APPSTORE_TOKEN). Signiert wird genau die Datei hinter der URL. Nach dem Store-Upload darf der Tag also nicht mehr verschoben werden, sonst passt die Signatur nicht mehr. latest-main wird abgelehnt. Kein appinfo/signature.json (Code-Signing): Das braucht der Store nicht, und Nextcloud prüft es bei Nicht-Core-Apps nur, wenn die Datei existiert.

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