Build package / package (push) Successful in 50s
Lint info.xml / xml-lint (push) Successful in 20s
Lint PHP / php-lint (8.2) (push) Successful in 58s
Lint PHP / php-lint (8.3) (push) Successful in 57s
Lint PHP / php-lint (8.4) (push) Successful in 55s
PHPUnit / unit-tests (push) Successful in 1m7s
The settings page rendered, but nothing in it worked: - All NcSelect dropdowns showed "undefined" for every option. In @nextcloud/vue 9 the `label` prop is vue-select's option display *key*, not a caption, so `label="Board"` read `option.Board`. Use `input-label`. - Saving always failed with "Bitte einen Titel angeben". Vue 3 dropped `.sync`; NcTextField and NcCheckboxRadioSwitch bind via `modelValue`, so `:value.sync` / `:checked.sync` never wrote back. Use `v-model`. - NcButton's style prop is now `variant`, `type` is the native button type and `native-type` is gone. `type="tertiary"` rendered `<button type="tertiary">`, which HTML falls back to `submit` for, making the cancel button submit the form. - The user dropdown was always empty. Deck's RelationalEntity replaces resolved relations with a RelationalObject once an entity is enriched, so `$acl->getParticipant()` returns that wrapper and the uid lives in `getPrimaryKey()` -- probing for `getUID()` yielded null. This also broke the background job's assigned-user filter, which shares the extractor. - A board's ACL never contains its owner (a private board has an empty ACL), so participants are now seeded with the owner, group ACL entries are expanded via IGroupManager and display names resolved via IUserManager. - One board appeared twice: getUserBoards() merges own/group/circle boards and includes archived and trashed ones. Deduplicate by id and drop those. Also keep one failing lookup in onBoardChange from taking the other two dropdowns down with it, and document all of the above in CLAUDE.md.
112 lines
17 KiB
Markdown
112 lines
17 KiB
Markdown
# 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](https://github.com/nextcloud/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`.
|
|
|
|
## 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:
|
|
|
|
```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
|
|
|
|
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
|
|
|
|
1. Bump `<version>` in `appinfo/info.xml` and `version` in `package.json` (keep them in sync).
|
|
2. Commit, push to `main`, confirm the lint/phpunit pipelines are green.
|
|
3. `git tag vX.Y.Z && git push origin vX.Y.Z` — this triggers the tag branch of `.gitea/workflows/build-main.yml`, which 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, two triggers
|
|
|
|
`.gitea/workflows/build-main.yml` handles both cases; there is deliberately no separate release workflow, because the build steps were identical and only the publishing target differed. It runs on pushes to `main` **and** on `v*` tags, and the final 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 workflow therefore needs Node only; a `setup-php` step here would be dead weight (the deleted release workflow had one).
|
|
|
|
**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` (5 min `TimedJob`) → `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()` merges own/group/circle boards and can return the same board twice, and it includes archived and trashed boards — `listBoardsForCurrentUser()` dedupes by id and drops those.
|
|
|
|
## 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.
|