- Boards: BoardService::getUserBoards() defaults to $includeArchived = true,
and that flag also gates the `deleted_at = 0` condition, so archived and
trashed boards showed up in the dropdown. Request the filtered query
instead, with a fallback to the no-arg call if the signature ever changes.
Stacks need nothing: StackMapper::findAll() always filters deleted_at.
- Each stack dropdown now hides whatever the other one holds, so source and
target can no longer be set to the same stack. The save-time check stays
as the backstop for rows stored before this rule.
- RunWorkflowsJob reads its interval from config.php
('workflow_deck_automation.interval', seconds, default 300, clamped to a
60s minimum). TimedJob re-reads the interval on every cron pass, so a
changed value takes effect without any occ command.
19 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
What this is
A Nextcloud app (workflow_deck_automation, namespace WorkflowDeckAutomation) targeting Nextcloud Hub 26 Spring (server 34.x). It lets each user configure, from their personal settings, automation "workflows" that move overdue Deck cards from one stack to another, with optional assigned-user/label filters and an email notification. A background TimedJob evaluates all enabled workflows every 5 minutes.
Non-negotiable architectural constraint
No HTTP/OCS calls against Deck, anywhere. Every read (boards, stacks, cards, labels, assigned users) 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.
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.
Per-user impersonation in the background job
Deck's ACL/permission checks read the live IUserSession, not a value frozen at construction. RunWorkflowsJob (a TimedJob) 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, per owner, temporarily impersonates them (IUserSession::setUser(), restored in a finally) before touching Deck. Don't remove this — without it, Deck's permission checks inside the cron run would be evaluated against no user (or the wrong user).
The filter-matching logic (WorkflowRunner::cardMatchesFilters(), ::isOverdue()) is deliberately static and side-effect-free so it's unit-testable without a real Deck installation — see tests/Unit/Service/WorkflowRunnerFilterTest.php.
Job interval comes from config.php
RunWorkflowsJob reads workflow_deck_automation.interval (seconds, default 300, clamped to a 60s minimum) via IConfig::getSystemValueInt() in its constructor. That works because TimedJob re-reads $this->interval on every cron pass from the freshly constructed job — so editing config.php takes effect immediately, with no occ command and no re-registration. Don't move this into appinfo/info.xml or a stored app config; the constants (INTERVAL_CONFIG_KEY, DEFAULT_INTERVAL, MINIMUM_INTERVAL) on the job are the single source of truth and are referenced from the README.
Registration is declarative, not Bootstrap-based
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.
Commands
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).
Commands as they'd run in CI/on a machine that has the tools:
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
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
To run a single PHPUnit test: vendor/bin/phpunit --filter testUserFilterIsOrAgainstAssignedUsers tests/Unit/Service/WorkflowRunnerFilterTest.php.
There is no meaningful way to test the Deck integration itself outside a real Nextcloud+Deck instance (php occ background-job:worker workflow_deck_automation) — the unit tests intentionally stop at the pure filter logic.
Release process
- Bump
<version>inappinfo/info.xmlandversioninpackage.json(keep them in sync). - Commit, push to
main, confirm the run of.gitea/workflows/build-main.ymlis green. 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, runsmake appstore, and attachesworkflow_deck_automation.tar.gzas the asset on a normal (non-pre-) release for that tag.
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.
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.
One workflow for everything
.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.
Consequences of that shape, all intentional:
packagebuilds and publishes in one job. Handing the tarball to a separate publish job would needactions/upload-artifact(unusable here, see above) oractions/cache— a second Gitea backend API in the critical path for no gain.- The workflow also runs on
pull_request, where thePublish releasestep is skipped viaif: gitea.event_name == 'push'and the job degrades into a frontend-build check. That matters:npm run buildused to run only onmain, so no PR ever exercised it. - Tags now get linted and tested too. Previously the check workflows triggered only on
mainpushes and PRs, so av*tag published completely unverified.
It runs on pushes to main and on v* tags, and the publish step branches on gitea.ref:
main→ rolling pre-release taggedlatest-main; both the release and the tag are deleted and recreated each run, so the download URL forworkflow_deck_automation.tar.gzalways serves the newestmainbuild.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 theRECREATE_TAGflag 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 fromtemplates/settings/personal.phpinto the section registered bylib/Settings/PersonalSection.php+lib/Settings/Personal.php) talks tolib/Controller/WorkflowController.php(anOCSController) viasrc/api.js, hitting OCS routes declared inappinfo/routes.php(/ocs/v2.php/apps/workflow_deck_automation/api/v1/...). - Storage:
lib/Db/Workflow.php(Entity) /lib/Db/WorkflowMapper.php(QBMapper) over thewfda_workflowstable, created inlib/Migration/Version1000Date20260813120000.php. Filter fields (filter_user_ids,filter_label_ids) are stored as JSON-encoded arrays in text columns, not join tables. - Automation:
lib/BackgroundJob/RunWorkflowsJob.php(5 minTimedJob) →lib/Service/WorkflowRunner.php(per-user impersonation + filtering, both filters OR-within-themselves and AND-between-each-other) →lib/Service/DeckIntegrationService.php(all actual Deck class calls) andlib/Service/NotificationMailer.php(email viaOCP\Mail\IMailerto the workflow owner's account address, never a manually-entered address). - Both the controller's board/stack/label/participant 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:
viteversion must satisfy@nextcloud/vite-config's peer requirement.package.jsonpins@nextcloud/vite-configto^2.2.0, which currently resolves to2.5.4and peer-requiresvite@^7.3.6. If you bump@nextcloud/vite-config, check itspeerDependencies.viteand bump ourvitedevDependency to match, ornpm installfails withERESOLVE.package.jsonneeds"type": "module".vite.config.jsusesimport/exportsyntax and@nextcloud/vite-configis ESM-only; without"type": "module", Node treats.jsas CommonJS andvite buildfails trying torequire()an ESM-only package.- A
tsconfig.jsonmust exist at the repo root, even though this project has no TypeScript source.@nextcloud/vite-config'sindex.jsbarrel statically re-exportscreateLibConfigfromlibConfig.js, which importsvite-plugin-dtsat module scope — that import chain runs just from importingcreateAppConfig, regardless of whethercreateLibConfigis ever called. The minimaltsconfig.jsonin 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. typescriptmust be an explicit devDependency.vite-plugin-dts(pulled in transitively by the bullet above, version^4.5.4as of@nextcloud/vite-config@2.5.4) peer-depends ontypescript: "*"but doesn't install it itself. Without atypescriptdevDependency in our ownpackage.json, nothing provides that package, andvite-plugin-dts's@volar/typescriptintegration crashes at module-load time — beforevite.config.js's own code ortsconfig.jsonare ever consulted — withCannot read properties of undefined (reading 'useCaseSensitiveFileNames')(proxyCreateProgram). This is the actual fix;tsconfig.jsonexisting is necessary but not sufficient. Keeptypescriptreasonably close to the version@nextcloud/vite-configitself develops against (currently^5.9.3) if you bump either.createAppConfigprefixes every entry name with the app id, so the entrypersonal-settingsis emitted asjs/workflow_deck_automation-personal-settings.mjsandcss/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.phptherefore passesApplication::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 isCould not find resource workflow_deck_automation/js/….js to load(jsresourceloader) plus a matchingCould not find resource file "/apps/workflow_deck_automation/css/….css"innextcloud.log. If you rename the entry invite.config.js, rename it inPersonal.phptoo.- The bulk of the CSS lives in a hashed
*.chunk.css, loaded at runtime by the.mjsbundle (cssCodeSplit: true). Thecss/workflow_deck_automation-personal-settings.cssthataddStyle()points at is the ~100-byte stub produced bycreateEmptyCSSEntryPoints: 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.NcTextFieldandNcCheckboxRadioSwitchboth bind throughmodelValue/update:modelValue, so it'sv-model="x"—:value.sync="x"/:checked.sync="x"leave the bound state permanently at its initial value. That's howform.titlestayed''and every save died on "Bitte einen Titel angeben." NcSelect'slabelprop is the option display key, not a caption (it's vue-select's). Passinglabel="Board"makes it readoption.Boardon every option and renderundefinedfor all of them. The visible caption prop isinput-label.NcButton:variantis the style,typeis the native button type, andnative-typeno longer exists (v8'stype="primary"+native-type="submit"→ v9'svariant="primary"+type="submit"). A leftovertype="primary"renders<button type="primary">, which HTML treats as the invalid-value defaultsubmit— so a cancel button silently submits the form.NcSelect'sreduceis not declared onNcSelectitself; it reaches vue-select through attribute fallthrough (NcSelect's root element is theVueSelectcomponent and it doesn't setinheritAttrs: false). It works, but it's fallthrough, not a documented prop.NcNoteCard's prop is stilltype(success|info|warning|error) — not everything was renamed tovariant.
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 settings UI's user dropdown came up empty and why the runner's assigned-user filter could never match. DeckIntegrationService::unwrapUid() handles both shapes (bare string and RelationalObject); route any new participant/owner field through it.
Two more Deck facts that bit us in the same pass: a board's ACL does not contain its owner (a private board has an empty ACL, so participants must be seeded with $board->getOwner()), and ACL entries can be groups (type === 1) whose members have to be expanded via IGroupManager.
BoardService::getUserBoards() defaults to $includeArchived = true, and that flag also gates the deleted_at = 0 condition — with the default you get archived and trashed boards back. We call getUserBoards(null, false) (with a fallback to the no-arg form if Deck ever changes the signature) and still dedupe by id afterwards, because it merges own/group/circle boards and can return the same board twice. 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.