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.
This commit is contained in:
1 parent
0904292c73
commit
ed684cca4f
1 file changed
+82
-133
@@ -1,191 +1,140 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
Leitfaden für Claude Code (claude.ai/code) in diesem Repository.
|
||||
|
||||
## What this is
|
||||
## Worum es geht
|
||||
|
||||
A Nextcloud app (`workflow_deck_automation`, namespace `WorkflowDeckAutomation`) targeting **Nextcloud Hub Summer 26 (server 35.x)** on this branch (`main` tracks Hub 26 Spring / server 34.x). It lets each user configure, from their personal settings, automation "workflows" that move overdue [Deck](https://github.com/nextcloud/deck) cards from one stack to another, with an optional email notification. There are deliberately **no card filters** (assigned users/labels existed until they were removed on request): a workflow moves *every* overdue card of its source stack. A background `Job` evaluates all enabled workflows on every cron pass.
|
||||
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.
|
||||
|
||||
## Non-negotiable architectural constraint
|
||||
## Harte Vorgabe: keine HTTP/OCS-Aufrufe gegen Deck
|
||||
|
||||
**No HTTP/OCS calls against Deck, anywhere.** Every read (boards, stacks, cards) and the card move itself must go through Deck's own internal PHP classes (`OCA\Deck\Service\CardService`, `StackService`, `BoardService`, `OCA\Deck\Db\CardMapper`, …), resolved in-process via `\OCP\Server::get()`. This was an explicit, repeated product requirement from the user — do not "fix" it by switching to `IClientService`/`curl` against `/ocs/...`, even though that would be the more conventional cross-app integration approach.
|
||||
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“.
|
||||
|
||||
All Deck access is funneled through `lib/Service/DeckIntegrationService.php` — it is the *only* class that references `OCA\Deck\*`. If you need new Deck data, extend that class rather than reaching into Deck internals from elsewhere. Every call in there is wrapped (`class_exists()` checks, `IAppManager::isEnabledForUser('deck', …)`, try/catch → `DeckUnavailableException`) because `OCA\Deck\*` is not a documented/stable public API — it has no `@since` markers and can change between Deck releases without notice.
|
||||
**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).
|
||||
|
||||
## Per-user impersonation in the background job
|
||||
## Impersonation im Hintergrundjob – Decks eingefrorene `$userId`
|
||||
|
||||
`RunWorkflowsJob` has no logged-in user by default and must evaluate workflows belonging to many different users within one PHP process. `lib/Service/WorkflowRunner.php` therefore groups workflows by owner and impersonates each owner in turn. **Impersonation here is two steps, and `IUserSession::setUser()` is the one that barely matters:**
|
||||
`WorkflowRunner` gruppiert Workflows nach Owner und impersoniert jeden nacheinander, in zwei Schritten (beide im `finally` via `clearImpersonation()` zurückgesetzt):
|
||||
|
||||
1. `IUserSession::setUser()` — for the handful of Nextcloud services that do read the live session, plus `IRootFolder::getUserFolder()` for Deck helpers that expect an initialised user FS.
|
||||
2. `DeckIntegrationService::beginUserContext()` — pins **Deck itself** to that user. This is the load-bearing half.
|
||||
1. `IUserSession::setUser()` + `IRootFolder::getUserFolder()` – für die wenigen Dienste, die die Session lesen. Nebensächlich.
|
||||
2. `DeckIntegrationService::beginUserContext()` – **der tragende Teil.**
|
||||
|
||||
Both are undone in a `finally` (`WorkflowRunner::clearImpersonation()`).
|
||||
**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.
|
||||
|
||||
**Deck does not read the session for permissions. At all.** `PermissionService` — the class behind every permission check, including the ones inside `CardService::reorder()` — takes the current user as `private ?string $userId`, and so do `CardService`, `BoardService` and `ActivityManager`. That string comes from the app container's `userId` service, which is `ISession::get('user_id')` registered via `registerService()`, i.e. **shared**: Pimple resolves it once and caches it for the whole process, and `ServerContainer` caches Deck's app container just as long. Whoever was active when Deck's container first built `PermissionService` is who every later check is evaluated against.
|
||||
`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).
|
||||
|
||||
In cron that is *never* the user being impersonated: `null` if any Deck-owned background job ran earlier in the same `cron.php` pass, otherwise the first workflow owner touched. `null` fails every check, so Deck answers `NoPermissionException` for perfectly intact boards. This produced a real bug — see the next section. `beginUserContext()` therefore swaps that private field directly (there is no API), records the old value, and `endUserContext()` puts it back. `PermissionService` is mandatory: if it can't be pinned, `beginUserContext()` throws and the runner **skips that user entirely** rather than acting under someone else's permissions. The other three are best-effort (they only affect activity attribution and unread counts).
|
||||
**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.
|
||||
|
||||
Consequence for board lists: the app does not use `BoardService::getUserBoards()` at all (it reads that frozen field). `listOwnedBoards()` goes through `BoardMapper::findAllByOwner($uid)` instead, which takes the uid explicitly; the job still has no use for a board *list* and asks `findStackIds()` about one known board id. Before using any other `OCA\Deck\*` service from the runner, check whether it carries a frozen `$userId` — the failure mode is silent and user-crossing, not an error.
|
||||
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.
|
||||
|
||||
The due-date logic (`WorkflowRunner::isOverdue()`) is deliberately `static` and side-effect-free so it's unit-testable without a real Deck installation — see `tests/Unit/Service/WorkflowRunnerTest.php`. Keep new pure logic in that shape; it is the only part of this app that has tests at all.
|
||||
## Workflows werden deaktiviert, wenn Board/Stack verschwinden
|
||||
|
||||
## Workflows are auto-disabled when their Deck targets vanish
|
||||
`WorkflowRunner::findBrokenTarget()` prüft vor jedem Lauf Board und Stacks; fehlt etwas, wird der Workflow abgeschaltet (`enabled = false`) und einmalig gemailt. Invarianten:
|
||||
|
||||
`WorkflowRunner::findBrokenTarget()` checks board and stacks before every run and, if something is gone, switches the workflow off (`enabled = false`) and sends a one-off mail. Two invariants hold this together and must survive refactors:
|
||||
- **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.
|
||||
|
||||
- **Only positive knowledge disables.** `DeckIntegrationService::findStackIds()` returns `null` only for "board row gone" (`DoesNotExistException`, Deck's `NotFoundException`), "board in the trash" (`deleted_at > 0`), or "this uid has no read permission" — and *throws* for everything else. The runner skips the workflow on a throw. If you make that method degrade to `[]`/`null` on generic errors, a Deck outage will disable every workflow on the instance and mail every user about it.
|
||||
- **`findStackIds()` must never ask a Deck service to figure out *who* is asking.** It takes the uid as an argument and is assembled from pieces that cannot answer for the wrong user: `BoardMapper`/`StackMapper` are plain QBMappers with no user state, and `PermissionService::getPermissions($boardId, $userId)` is handed the uid explicitly instead of falling back to the frozen field. Do not "simplify" it back to a single `StackService::findAll($boardId)` — that is precisely what broke. `StackService::findAll()` runs `checkPermission()` with no uid, which falls through to the construction-time `$userId`; with that frozen to `null` in cron, Deck threw `NoPermissionException` for an untouched board, the old code read that as "board is gone", and users got "your workflow was deactivated" mails for boards sitting right in front of them. This layer stays correct even if `beginUserContext()` silently degrades.
|
||||
- **`NoPermissionException` is not evidence of anything.** It is deliberately *not* in `isMissing()`. Deck raises it for every failed check regardless of cause.
|
||||
- **The mail ignores the `notifyEmail` flag** — that flag is about moved cards; this is a notice that the user's configuration stopped working. It stays a one-off simply because a disabled workflow is not picked up by `findAllEnabled()` again.
|
||||
Archivierte Boards lösen das nicht aus (sie bleiben lesbar), werden aber in den Dropdowns ausgeblendet.
|
||||
|
||||
Archived boards deliberately do *not* trigger this (they stay readable, so `findStackIds()` succeeds), even though they're filtered out of the settings dropdowns.
|
||||
## Ein Workflow gehört dem Board-Owner, nicht seinem Ersteller
|
||||
|
||||
## A workflow belongs to its board's owner, not its creator
|
||||
Wird ein Board in Deck übertragen, wandern seine Workflows mit.
|
||||
|
||||
The relation is **board owner ↔ workflow**, not creator ↔ workflow. When Deck transfers a board, its workflows move to the new owner.
|
||||
- **„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.
|
||||
|
||||
- **Which boards count as "mine"**: `DeckIntegrationService::listOwnedBoards($uid)` → Deck's `BoardMapper::findAllByOwner($uid)`, one `WHERE owner = :uid` query — shared boards never come back, and as a plain QBMapper it carries no frozen uid. Trashed boards are dropped; archived ones are kept with `archived: true` so their workflows stay visible, but they're hidden from the board dropdown and rejected by `validate()`. It *throws* rather than returning `[]`, since `[]` would silently hide all of the user's workflows.
|
||||
- **Listing/editing/deleting** is purely `board_id IN (listOwnedBoards)` (`WorkflowMapper::findAllForBoards()`, `WorkflowController::findOwnWorkflow()`). `user_id` plays no part, so a transferred board's workflows show up for the new owner immediately — no cron pass needed. Workflows whose board is gone disappear from the UI; the runner disables them and mails the last owner.
|
||||
- **Creating/saving** validates the board against the same list. `create()`/`update()` set `user_id` to the current user.
|
||||
- **The runner** needs a single user to impersonate, so it calls `WorkflowRunner::followBoardOwner()` → `DeckIntegrationService::findBoardOwner()` (plain `BoardMapper::find()`) before grouping: a known owner different from `user_id` rewrites and persists it, so the run is impersonated as — and mails go to — the current owner. A gone board keeps its last owner, so `findBrokenTarget()` can still disable it and mail them.
|
||||
`user_id` heißt also nur „als wer läuft der Job / wer bekommt Mail“.
|
||||
|
||||
`user_id` is therefore only "who the job runs as / who gets mail", refreshed from the board owner on every run — not the source of truth for who sees a workflow. Don't reintroduce `user_id` lookups in the controller.
|
||||
## Kartenfilter wurden entfernt
|
||||
|
||||
## Card filters were removed — don't bring them back casually
|
||||
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).
|
||||
|
||||
Workflows used to carry optional assigned-user and label filters (`filter_user_ids` / `filter_label_ids`, JSON id lists). They were removed on request; `Version1001Date20260930120000` drops both columns, and workflows that had a filter kept running *unfiltered* — an explicit decision, not an oversight. The columns must stay dropped rather than ignored: `Entity::fromRow()` calls a setter per selected column, and `Workflow` no longer has those properties.
|
||||
## Der Job hat kein Intervall
|
||||
|
||||
With them went everything that only existed for them: the labels/participants endpoints, `listLabels()`/`listParticipants()`/`findFilterOptions()`, the dead-filter warnings, and `CardService::enrichCards()` in `getActiveCardsInStack()` (it only added labels/assignees/comment counts, and runs `BoardService::find()` — a permission check against Deck's frozen uid).
|
||||
`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.
|
||||
|
||||
## The job has no interval — one run per cron pass
|
||||
**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.
|
||||
|
||||
`RunWorkflowsJob` extends `OCP\BackgroundJob\Job`, **not** `TimedJob`, so it runs every time cron picks it up. There is no interval and no knob: the cadence *is* the instance's cron cadence. Don't reintroduce one — an earlier version read `workflow_deck_automation.interval` from `config.php` (default 300, 60s floor), and that config key is gone as of v0.2.0. `TimedJob` with `setInterval(0)` would behave nearly identically but keeps an interval concept nothing uses.
|
||||
|
||||
**Time sensitivity is what actually decides whether the job runs at all.** `OC\Core\Service\CronService::runCli()` reads `maintenance_window_start` (default `100`, i.e. unset) and, whenever the current UTC hour is outside `[start, start+4]`, calls `jobList->getNext($onlyTimeSensitive = true)` — time-insensitive jobs are then skipped for the other 20 hours of the day. This app shipped `TIME_INSENSITIVE` once: on an instance with a maintenance window configured (Nextcloud's admin overview nags admins into setting one) the job simply never ran, showing `last_run = 1970-01-01` in `occ background-job:list` indefinitely. Note this gate only exists in `runCli()`, so it doesn't apply to ajax/webcron mode, and passing job classes explicitly (`occ background-job:worker '<class>'`) bypasses it — which is exactly why manual testing can look fine while cron does nothing.
|
||||
|
||||
Being a plain `Job` makes that unreachable rather than merely declared. `getNext()` filters on the `time_sensitive` *column* of `oc_jobs`; `JobList::add()` doesn't write the column at all (it takes the `TIME_SENSITIVE` default), and the only place that ever lowers it is a one-way ratchet in `JobList::setLastRun()`:
|
||||
|
||||
```php
|
||||
if ($job instanceof TimedJob && !$job->isTimeSensitive()) {
|
||||
$query->set('time_sensitive', $query->createNamedParameter(IJob::TIME_INSENSITIVE));
|
||||
}
|
||||
```
|
||||
|
||||
There is no `else`, and a non-`TimedJob` never enters it. Note `setTimeSensitivity()`/`isTimeSensitive()` live on `TimedJob`, not on `Job` — so there is nothing to declare here, and nothing that can go wrong.
|
||||
|
||||
**Rows written by an older version keep their stamp.** Once stamped `TIME_INSENSITIVE` it stays that way: re-registering an existing job (app update, `app:enable`) takes `add()`'s update branch, which leaves the column alone. An instance that ran the pre-v0.1.0-fix build still needs its row fixed once:
|
||||
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';
|
||||
```
|
||||
|
||||
or, without DB access, `occ background-job:delete <id>` followed by `occ app:disable workflow_deck_automation && occ app:enable workflow_deck_automation` to get a freshly inserted row. Fresh installs are unaffected.
|
||||
oder `occ background-job:delete <id>`, dann `occ app:disable workflow_deck_automation && occ app:enable workflow_deck_automation`.
|
||||
|
||||
To debug this job on a server: `occ background-job:list --class '<class>'` for its id and last run, then `occ background-job:execute <id> --force-execute` to run it right now without waiting for cron. `occ background-job:worker` takes **job classes**, not an app id.
|
||||
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.
|
||||
|
||||
## Registration is declarative, not Bootstrap-based
|
||||
## Registrierung deklarativ statt über Bootstrap
|
||||
|
||||
Background jobs and classic personal settings are registered in `appinfo/info.xml` (`<background-jobs>`, `<settings><personal>/<personal-section>`), **not** via `IRegistrationContext` in `lib/AppInfo/Application.php` — that interface has no `registerBackgroundJob()`/`registerSettings()` methods on NC 34. `Application.php` is intentionally near-empty.
|
||||
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.
|
||||
|
||||
## Commands
|
||||
## Architektur
|
||||
|
||||
**PHP and npm are not available in the local dev sandbox this repo is normally edited from.** Do not try to run `composer`, `php`, or `npm` locally to verify changes — they will fail with "command not found". Verification happens by pushing to `main` (or opening a PR) and checking the Gitea Actions run; the self-hosted runner tag is `gitea-runner-server03` (see `.gitea/workflows/*.yml`, all pinned to that `runs-on:` label).
|
||||
- **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`.
|
||||
|
||||
Commands as they'd run in CI/on a machine that has the tools:
|
||||
## 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 over lib/ and tests/
|
||||
composer run cs:check # nextcloud/coding-standard (php-cs-fixer); composer run cs:fix to auto-fix
|
||||
composer run test:unit # PHPUnit — pure logic only, no live Deck/Nextcloud instance needed
|
||||
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 # no package-lock.json is committed (gitignored) — use install, not `npm ci`
|
||||
npm run build # production build
|
||||
npm run watch # rebuild on change during development
|
||||
npm install # nicht `npm ci`: Lockfiles sind absichtlich nicht committet
|
||||
npm run build # bzw. npm run watch
|
||||
```
|
||||
|
||||
To run a single PHPUnit test: `vendor/bin/phpunit --filter testIsOverdueReturnsTrueForPastDueDate tests/Unit/Service/WorkflowRunnerTest.php`.
|
||||
`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.
|
||||
|
||||
There is no meaningful way to test the Deck integration itself outside a real Nextcloud+Deck instance (`php occ background-job:worker 'OCA\WorkflowDeckAutomation\BackgroundJob\RunWorkflowsJob'` — the argument is a job *class*, not an app id) — the unit tests intentionally stop at the pure due-date logic.
|
||||
## Release und CI
|
||||
|
||||
### Release process
|
||||
**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.
|
||||
|
||||
**Versioning scheme (from 34.0.0 on): `<nextcloud-major>.<minor>.<patch>`.** The leading number tracks the minimum/maximum Nextcloud major version declared in `appinfo/info.xml`'s `<dependencies><nextcloud .../>` (on this branch `min-version="35" max-version="35"`), not this app's own feature history - so it only changes when the supported Nextcloud major changes, and resets `minor`/`patch` to `0.0` at that point. Ordinary fixes/features within the same supported NC major bump `minor`/`patch` as usual (semver-ish, without a leading `0.`). Versions before `34.0.0` (`0.1.x`/`0.2.x`) predate this scheme.
|
||||
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`.
|
||||
|
||||
This branch (`nextcloud-35`) is a permanent, separately maintained version line for Nextcloud 35, started at `35.0.0` alongside the `34.x.x` line on `main`. Backport fixes between the two lines manually where relevant; they are not merged into each other.
|
||||
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.
|
||||
|
||||
1. Bump `<version>` in `appinfo/info.xml` and `version` in `package.json` (keep them in sync).
|
||||
2. Commit, push to `main`, confirm the run of `.gitea/workflows/build-main.yml` is green.
|
||||
3. `git tag vX.Y.Z && git push origin vX.Y.Z` — this triggers the tag branch of the same workflow, which re-runs the checks, builds the frontend, runs `make appstore`, and attaches `workflow_deck_automation.tar.gz` as the asset on a normal (non-pre-) release for that tag.
|
||||
**`.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.
|
||||
|
||||
**If the tagged build fails and no artifact was ever produced**, fix the issue on `main` and *move* the existing tag to the fixed commit instead of bumping to a new version number (`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`) — this is how `v0.1.0` was handled through several build fixes before it first built successfully. The job deletes any pre-existing *release* for the tag before creating the new one, so a moved tag republishes cleanly instead of failing on a duplicate asset name. Once a version has actually produced a published artifact, don't move its tag anymore; bump normally instead.
|
||||
- `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.
|
||||
|
||||
**No `actions/upload-artifact` anywhere — and don't reintroduce it.** This self-hosted Gitea instance identifies as GHES to the official `actions/*` JS actions, which refuse to run `@actions/artifact` v2.0.0+ (used internally by `upload-artifact@v4` and later) against GHES: `GHESNotSupportedError`, which pinned us to `@v3` for a while. Publishing through the Gitea release REST API with `curl` sidesteps that version-specific backend API entirely, so the build workflow uses that instead. `actions/checkout` and `actions/setup-node` were never affected — only the artifact upload/download actions are, because they're the ones talking to a versioned backend rather than just running local commands — and can stay on their latest majors.
|
||||
## Frontend-Build-Fallen (alle erst in CI aufgefallen)
|
||||
|
||||
### One workflow for everything
|
||||
- **`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.
|
||||
|
||||
`.gitea/workflows/build-main.yml` is the *only* workflow: it carries the checks (`php-lint` across 8.2/8.3/8.4, `xml-lint`, `unit-tests`) and the `package` job that builds and publishes. They used to be four separate workflow files, which meant they all triggered independently on the same push and a `latest-main` release was published even when PHPUnit was red. `needs:` only works between jobs of the same workflow (and Gitea's `workflow_run` can depend on one workflow at a time), so gating the release on the checks required merging them into this file. Don't split them back out.
|
||||
## `@nextcloud/vue` 9 (Vue 3) – v8-Idiome scheitern still
|
||||
|
||||
Consequences of that shape, all intentional:
|
||||
- **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`).
|
||||
|
||||
- **`package` builds *and* publishes in one job.** Handing the tarball to a separate publish job would need `actions/upload-artifact` (unusable here, see above) or `actions/cache` — a second Gitea backend API in the critical path for no gain.
|
||||
- **The workflow also runs on `pull_request`**, where the `Publish release` step is skipped via `if: gitea.event_name == 'push'` and the job degrades into a frontend-build check. That matters: `npm run build` used to run only on `main`, so no PR ever exercised it.
|
||||
- **Tags now get linted and tested too.** Previously the check workflows triggered only on `main` pushes and PRs, so a `v*` tag published completely unverified.
|
||||
## Deck-Interna, die man kennen muss
|
||||
|
||||
It runs on pushes to `main` **and** on `v*` tags, and the publish step branches on `gitea.ref`:
|
||||
|
||||
- **`main`** → rolling pre-release tagged `latest-main`; both the release *and* the tag are deleted and recreated each run, so the download URL for `workflow_deck_automation.tar.gz` always serves the newest `main` build.
|
||||
- **`v*`** → normal release on the tag that was just pushed. Only a stale release is deleted here — **never the tag**, since deleting it would destroy the ref that triggered the run. That's what the `RECREATE_TAG` flag guards.
|
||||
|
||||
The `latest-main` half exists so a build can be grabbed and copied onto the server (`nextcloud.gnilebein.de`) without cutting a version tag first — it produced `v0.1.0`-shaped raw-repo confusion once already when the app directory was populated by copying the git working tree instead of a built package; this gives a one-click alternative to that mistake.
|
||||
|
||||
Note that `make appstore` is pure `mkdir`/`tar`/`rm` — no `composer`, no `php`. The `package` job therefore needs Node only; a `setup-php` step *there* would be dead weight (the deleted release workflow had one). PHP is set up in the check jobs, which is a different job and a different container.
|
||||
|
||||
**No manually created token is needed.** The publish step authenticates with `secrets.GITEA_TOKEN`, the token Gitea injects into every Actions job automatically (Gitea 1.27.1 here). The job declares `permissions: contents: write`, which Gitea maps to *Code: write* (needed to delete the `latest-main` tag) plus *Releases: write* (creating the release and uploading the asset).
|
||||
|
||||
This replaced an earlier hand-made `RELEASE_TOKEN` repo secret. If the publish step ever starts returning 403s, check the repo/org **default token permission mode**: `permissions:` requests are clamped by `MaxTokenPermissions`, so a repository switched from *Permissive* (the backwards-compatible default) to *Restricted* silently downgrades releases to read-only and no `permissions:` block can raise it back. That's a repo setting, not a workflow bug — don't "fix" it by reintroducing a PAT before checking.
|
||||
|
||||
## Data flow / architecture map
|
||||
|
||||
- **UI**: `src/PersonalSettings.vue` (Vue 3, mounted from `templates/settings/personal.php` into the section registered by `lib/Settings/PersonalSection.php` + `lib/Settings/Personal.php`) talks to `lib/Controller/WorkflowController.php` (an `OCSController`) via `src/api.js`, hitting OCS routes declared in `appinfo/routes.php` (`/ocs/v2.php/apps/workflow_deck_automation/api/v1/...`).
|
||||
- **Storage**: `lib/Db/Workflow.php` (Entity) / `lib/Db/WorkflowMapper.php` (QBMapper) over the `wfda_workflows` table, created in `lib/Migration/Version1000Date20260813120000.php`. `Version1001Date20260930120000` drops the former filter columns.
|
||||
- **Automation**: `lib/BackgroundJob/RunWorkflowsJob.php` (plain `Job`, one run per cron pass) → `lib/Service/WorkflowRunner.php` (per-owner impersonation, moves every overdue card) → `lib/Service/DeckIntegrationService.php` (all actual Deck class calls) and `lib/Service/NotificationMailer.php` (email via `OCP\Mail\IMailer` to the workflow owner's account address, never a manually-entered address).
|
||||
- Both the controller's board/stack lookups (for populating the settings UI dropdowns) and the runner's card reads/moves go through the same `DeckIntegrationService` — there is no separate read path.
|
||||
|
||||
## Composer/npm lockfiles are intentionally not committed
|
||||
|
||||
`.gitignore` excludes `composer.lock` and `package-lock.json`. This means CI must use `composer install`/`npm install`, not `composer install --no-dev` assumptions tied to a lock file or `npm ci` (which hard-requires a lock file and will fail otherwise — this has already broken the build workflow once). If you add a lockfile-dependent step, either commit the lockfile deliberately or keep using the non-`ci`/non-locked install form.
|
||||
|
||||
## Frontend build gotchas already hit (don't reintroduce)
|
||||
|
||||
None of these were discoverable locally — there's no npm here (see "Local-only notes" below), so all three were only caught by an actual Gitea Actions run against the build workflow:
|
||||
|
||||
- **`vite` version must satisfy `@nextcloud/vite-config`'s peer requirement.** `package.json` pins `@nextcloud/vite-config` to `^2.2.0`, which currently resolves to `2.5.4` and peer-requires `vite@^7.3.6`. If you bump `@nextcloud/vite-config`, check its `peerDependencies.vite` and bump our `vite` devDependency to match, or `npm install` fails with `ERESOLVE`.
|
||||
- **`package.json` needs `"type": "module"`.** `vite.config.js` uses `import`/`export` syntax and `@nextcloud/vite-config` is ESM-only; without `"type": "module"`, Node treats `.js` as CommonJS and `vite build` fails trying to `require()` an ESM-only package.
|
||||
- **A `tsconfig.json` must exist at the repo root, even though this project has no TypeScript source.** `@nextcloud/vite-config`'s `index.js` barrel statically re-exports `createLibConfig` from `libConfig.js`, which imports `vite-plugin-dts` at module scope — that import chain runs just from importing `createAppConfig`, regardless of whether `createLibConfig` is ever called. The minimal `tsconfig.json` in this repo exists to give that a config to resolve, not because we write TypeScript. On its own this did **not** fix the crash below — see the next bullet.
|
||||
- **`typescript` must be an explicit devDependency.** `vite-plugin-dts` (pulled in transitively by the bullet above, version `^4.5.4` as of `@nextcloud/vite-config@2.5.4`) peer-depends on `typescript: "*"` but doesn't install it itself. Without a `typescript` devDependency in our own `package.json`, nothing provides that package, and `vite-plugin-dts`'s `@volar/typescript` integration crashes at module-load time — before `vite.config.js`'s own code or `tsconfig.json` are ever consulted — with `Cannot read properties of undefined (reading 'useCaseSensitiveFileNames')` (`proxyCreateProgram`). This is the actual fix; `tsconfig.json` existing is necessary but not sufficient. Keep `typescript` reasonably close to the version `@nextcloud/vite-config` itself develops against (currently `^5.9.3`) if you bump either.
|
||||
- **`createAppConfig` prefixes every entry name with the app id**, so the entry `personal-settings` is emitted as `js/workflow_deck_automation-personal-settings.mjs` and `css/workflow_deck_automation-personal-settings.css`. `Util::addScript()`/`addStyle()` take that *full on-disk basename* (minus extension), not the bare entry name — `lib/Settings/Personal.php` therefore passes `Application::APP_ID . '-personal-settings'`. Getting this wrong produces a **silently blank settings page**: the template's empty mount `<div>` renders fine, and the only symptom is `Could not find resource workflow_deck_automation/js/….js to load` (`jsresourceloader`) plus a matching `Could not find resource file "/apps/workflow_deck_automation/css/….css"` in `nextcloud.log`. If you rename the entry in `vite.config.js`, rename it in `Personal.php` too.
|
||||
- **The bulk of the CSS lives in a hashed `*.chunk.css`**, loaded at runtime by the `.mjs` bundle (`cssCodeSplit: true`). The `css/workflow_deck_automation-personal-settings.css` that `addStyle()` points at is the ~100-byte stub produced by `createEmptyCSSEntryPoints: true`. A near-empty entry CSS file is expected — don't "fix" it by turning off code splitting.
|
||||
|
||||
## `@nextcloud/vue` 9 component API (Vue 3) — the v8 idioms silently do nothing
|
||||
|
||||
This app is on `@nextcloud/vue` ^9 / Vue 3. The v8 (Vue 2) prop spellings that most Nextcloud app code online still uses either fail silently or, worse, land as stray DOM attributes instead of erroring:
|
||||
|
||||
- **No `.sync`.** Vue 3 removed it. `NcTextField` and `NcCheckboxRadioSwitch` both bind through `modelValue`/`update:modelValue`, so it's `v-model="x"` — `:value.sync="x"` / `:checked.sync="x"` leave the bound state permanently at its initial value. That's how `form.title` stayed `''` and every save died on "Bitte einen Titel angeben."
|
||||
- **`NcSelect`'s `label` prop is the *option display key*, not a caption** (it's vue-select's). Passing `label="Board"` makes it read `option.Board` on every option and render `undefined` for all of them. The visible caption prop is `input-label`.
|
||||
- **`NcButton`: `variant` is the style, `type` is the native button type**, and `native-type` no longer exists (v8's `type="primary"` + `native-type="submit"` → v9's `variant="primary"` + `type="submit"`). A leftover `type="primary"` renders `<button type="primary">`, which HTML treats as the invalid-value default `submit` — so a *cancel* button silently submits the form.
|
||||
- `NcSelect`'s `reduce` is not declared on `NcSelect` itself; it reaches vue-select through attribute fallthrough (NcSelect's root element *is* the `VueSelect` component and it doesn't set `inheritAttrs: false`). It works, but it's fallthrough, not a documented prop.
|
||||
- `NcNoteCard`'s prop is still `type` (`success|info|warning|error`) — not everything was renamed to `variant`.
|
||||
|
||||
## Deck entities are "relational": getters don't return the uid string
|
||||
|
||||
Deck's `RelationalEntity` swaps resolved relations for an `OCA\Deck\Db\RelationalObject` once an entity is enriched. So on an enriched board/card, `$acl->getParticipant()`, `$board->getOwner()` and a card assignment's participant return a `RelationalObject` (wrapping `OCA\Deck\Db\User`/`Group`/`Circle`), **not** a uid — the uid is its `getPrimaryKey()`. Probing for `getUID()` on the returned object finds nothing and yields `null`, which is how the (since removed) user filter once came up empty. `DeckIntegrationService::unwrapUid()` handles both shapes (bare string and `RelationalObject`); route any new participant/owner field through it.
|
||||
|
||||
Two more Deck facts worth keeping in mind if participants are ever needed again: a board's ACL does **not** contain its owner (a private board has an empty ACL), and ACL entries can be groups (`type === 1`) whose members have to be expanded via `IGroupManager`.
|
||||
|
||||
**`BoardMapper::findAllByOwner()` has no archived/`deleted_at` filter at all** — trashed and archived boards come back too, which is why `listOwnedBoards()` drops trashed ones and flags archived ones itself. (`BoardService::getUserBoards()`, used earlier, merges own/shared/group/circle boards and is no longer used.) Stacks need no such handling: `StackMapper::findAll()` filters `deleted_at = 0` unconditionally and stacks have no archived state.
|
||||
|
||||
## Local-only notes
|
||||
|
||||
`CLAUDE.local.md` (gitignored) carries session-specific environment notes (e.g. "no PHP/npm available locally"). Check it at the start of work in this repo — it's not duplicated here since it can change independently of the committed guidance.
|
||||
- **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.
|
||||
Reference in new issue
Block a user