revert 0e66319a06
Build package / php-lint (8.4) (push) Successful in 43s
Build package / php-lint (8.5) (push) Successful in 40s
Build package / xml-lint (push) Successful in 12s
Build package / unit-tests (push) Successful in 38s
Build package / package (push) Successful in 52s

revert 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.
This commit is contained in:
2026-08-26 07:10:55 +02:00
parent 0e66319a06
commit 28b4edc811
13 changed files with 41 additions and 390 deletions
+2 -35
View File
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## 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](https://github.com/nextcloud/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.
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 `Job` evaluates all enabled workflows on every cron pass.
## Non-negotiable architectural constraint
@@ -12,27 +12,6 @@ A Nextcloud app (`workflow_deck_automation`, namespace `WorkflowDeckAutomation`)
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:**
@@ -48,19 +27,7 @@ In cron that is *never* the user being impersonated: `null` if any Deck-owned ba
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.
The filter logic (`WorkflowRunner::cardMatchesFilters()`, `::isOverdue()`, `::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.
## Workflows are auto-disabled when their Deck targets vanish