Files
nextcloud-workflow-deck-aut…/CLAUDE.md
T
Patrick Niebeling 0e66319a06
Build package / php-lint (8.4) (push) Successful in 39s
Build package / php-lint (8.5) (push) Successful in 43s
Build package / xml-lint (push) Successful in 13s
Build package / unit-tests (push) Failing after 39s
Build package / package (push) Skipped
Let each workflow watch the due date or the start date
Deck 1.18 added a start date to cards (Card::$startdate), so a workflow
no longer has to mean "overdue". Each rule now carries a date_field
('due' | 'start'), chosen in the settings form next to board and stacks.

Existing rules keep the due date: the new column's default supplies it
for every stored row, so there is no backfill step, and
Workflow::getDateFieldOrDue() covers entities that were never near the
database as well as hand-edited values. The controller is the one place
that rejects an unknown value instead of normalising it - a client
asking for a rule it would not get should hear about it.

The probe for the field is property_exists(), not method_exists():
Deck's entities declare no getter as real code - getStartdate(),
getDuedate(), even getId() all go through Entity::__call(), which
method_exists() ignores by definition. A method_exists() guard would
have been false on every Deck version and would have turned every
start-date rule into a silent no-op.

assertDeckAvailable() now also refuses Deck older than 1.18.0. That
floor cannot live in appinfo/info.xml - neither the server's nor the app
store's schema has an app-to-app dependency element - but
<nextcloud min-version="34"> already implies it, since 1.18.x is the
only Deck release line published for Nextcloud 34.

isOverdue() becomes hasPassed(): one function for both dates, since the
comparison and the argument against Deck's day-truncating
getDaysUntilDue() are identical for either.

Also carries a pending NcSelect icon-alignment fix that was already in
the working tree.

Bump to 34.1.0.
2026-08-25 15:28:30 +02:00

32 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 Deck cards from one stack to another once their due date or start date has passed, with optional assigned-user/label filters and an email notification. A background Job evaluates all enabled workflows on every cron pass.

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.

method_exists() cannot see Deck's getters — use property_exists()

Card, Board, Label, Acl and Assignment declare no getters as real code. getStartdate(), getDuedate(), getDeletedAt(), getArchived(), even getId() are @method docblocks resolved by OCP\AppFramework\Db\Entity::__call() (Entity itself declares neither getId() nor setId()). method_exists() ignores __call() by definition, so every method_exists($deckEntity, 'getSomething') probe in this app is permanently false, on every Deck version, and the guarded branch is dead code that fails open or closed without a sound.

DeckIntegrationService::getCardDate() therefore probes property_exists($card, 'startdate') — the protected property declaration is visible, and it is exactly what Entity::getter() checks before throwing BadFunctionCallException. Use that shape for any new optional Deck field.

Known dead probes still in the tree (each one a separate fix, none of them yet made — verify against a live instance before touching, since the symptoms are silent):

  • getCardLabelIds() / extractParticipantUid() — always return []/null, so label and assigned-user filters cannot match any card, and ACL participants never reach the settings dropdown. Only the board owner survives, because RelationalObject::getPrimaryKey() is real code — which is why the bug is invisible on a private board (empty ACL, owner = the user themselves) and looks fixed.
  • getActiveCardsInStack()'s getDeletedAt check — trashed cards are moved.
  • findStackIds()'s getDeletedAt check — a board in the trash is not recognised as gone.
  • isBoardHidden() — dead, but masked by getUserBoards(null, false) doing the filtering server-side.

The Deck version floor lives in code, because info.xml cannot express it

DeckIntegrationService::MIN_DECK_VERSION (1.18.0) plus the version_compare() in assertDeckAvailable() is the only enforcement point for "this app needs a recent Deck". Neither the server's resources/app-info.xsd nor the app store's info.xsd has an app-to-app dependency element — <dependencies> takes only php, database, command, lib, owncloud, nextcloud, architecture, backend. Don't go looking for a <app min-version=…>; it does not exist in either schema.

<nextcloud min-version="34" max-version="34"/> already does most of the work and is the strictest pin available: Deck 1.18.x is the only Deck release line declaring >=34.0.0,<35.0.0, and 1.18.0 is exactly the release that added the card start date (Card::$startdate, PR #7749). So on any Nextcloud this app installs on, Deck already has that field, and 1.17.x cannot be enabled at all (its own max-version is 33, so a server upgrade to 34 disables it). What the version check still covers is a Deck dropped in by hand or checked out from git.

version_compare() sorts 1.18.0-beta.x below 1.18.0. That is intended — pre-releases of that line may predate the field. Since assertDeckAvailable() is called from both the controller (→ OCSPreconditionFailedException, visible as an error on the settings page) and the runner (→ skip + info log), a too-old Deck degrades in both places instead of failing halfway through a card move.

Per-user impersonation in the background job

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:

  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.

Both are undone in a finally (WorkflowRunner::clearImpersonation()).

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.

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

Consequence for getUserBoards(): listBoardsForCurrentUser() stays request-only anyway — Deck may already have cached board lists behind that field, and the job has no use for a board list; it 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.

The filter logic (WorkflowRunner::cardMatchesFilters(), ::hasPassed(), ::findDeadFilters()) is deliberately static and side-effect-free so it's unit-testable without a real Deck installation — see tests/Unit/Service/WorkflowRunnerFilterTest.php. Keep new pure logic in that shape; it is the only part of this app that has tests at all.

Due date or start date, per workflow

Each workflow carries a date_field ('due' | 'start', Workflow::DATE_FIELD_*) deciding which Deck card date it watches. WorkflowRunner::hasPassed() (formerly isOverdue()) is one function for both, because the comparison is identical — the raw timestamp against $now — and the whole argument against Deck's day-truncating getDaysUntilDue() applies unchanged to the start date. What differs is only the wording, in the UI and in NotificationMailer.

Three places keep an unknown value from becoming a surprise trigger, and all three resolve towards the due date, because that is what every workflow meant before the choice existed:

  • the column default 'due', which is also the entire migration for existing rows (Version1001Date20260825120000, a separate step because Version1000… is wrapped in hasTable() and would never reach an existing install),
  • Workflow::getDateFieldOrDue(), for entities that were never near the database and for hand-edited rows,
  • DeckIntegrationService::CARD_DATE_PROPERTIES's ?? DEFAULT_CARD_DATE.

The controller is the exception: it rejects an unknown dateField instead of normalising it, since a client asking for a rule it would not get should hear about it.

Workflows are auto-disabled when their Deck targets vanish

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:

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

Archived boards deliberately do not trigger this (they stay readable, so findStackIds() succeeds), even though they're filtered out of the settings dropdowns.

Dead filters are surfaced, never disabled

findBrokenTarget() covers the board and the two stacks — not filter_user_ids / filter_label_ids. Those are plain id lists compared with array_intersect() in cardMatchesFilters(), so an entry that no longer exists simply stops matching. With three labels of which one was deleted that is exactly right, and disabling would be wrong.

The pathological case is a filter whose entries are all gone: the workflow keeps running, updates last_run, and matches nothing — forever, with no mail, no log line and a healthy-looking row. It happens on deleted labels and just as easily on a user losing board access, because Deck's BoardService::deleteAcl() calls assignedUsersMapper->deleteByParticipantOnBoard() and wipes that user's card assignments.

Two low-stakes signals, no state and no migration:

  • WorkflowRunner::warnAboutDeadFilters() logs a warning on every run.
  • workflowIssues in src/PersonalSettings.vue marks the row red.

Both obey the same rule as the disable path: only positive knowledge. DeckIntegrationService::findFilterOptions() therefore throws instead of degrading to [] — unlike its siblings listLabels()/listParticipants(), which are wrapped in call(…, []) for the dropdowns, where an empty list is harmless. On the JS side the per-board caches store null for a failed request, and isDeadFilter() returns false for null; storing [] there would report every stack and filter of the board as deleted the moment one request fails. Don't collapse those two states back together.

Deliberately no mail and no enabled = false: one board edit can revive the filter, and there is no "already warned" column to keep a mail one-off with (unlike the disable case, which is one-off for free because a disabled workflow is never picked up again).

The job has no interval — one run per cron pass

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():

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:

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.

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.

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 'OCA\WorkflowDeckAutomation\BackgroundJob\RunWorkflowsJob' — the argument is a job class, not an app id) — the unit tests intentionally stop at the pure filter logic.

Release process

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 .../> (currently min-version="34" max-version="34"), not this app's own feature history - so it only changes when the supported Nextcloud major changes (e.g. dropping/adding NC 34 support bumps to 35.0.0), 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. 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.

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:

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

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. 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 (plain Job, one run per cron pass) → 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) 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/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:

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