Adds an info-level log line when a card passes a workflow's overdue/
filter checks, and another once the move to the target stack
succeeds - so a run can be traced per card/workflow in the log,
filterable by the app id.
A filter whose entries have all been deleted from the board was the one
failure mode of this app that was completely invisible: the workflow kept
running, kept updating last_run and matched nothing, forever. It happens
with deleted labels and just as easily when a filtered user loses board
access, because Deck's BoardService::deleteAcl() calls
assignedUsersMapper->deleteByParticipantOnBoard() and wipes that user's
card assignments.
Two low-stakes signals, no new column and no migration:
- WorkflowRunner::warnAboutDeadFilters() logs a warning on every run.
- workflowIssues in PersonalSettings.vue marks the row red.
Deliberately no mail and no `enabled = false`. One deleted label out of
three is harmless, and even a fully dead filter can be one board edit away
from being live again.
Both obey the rule the disable path already follows: only positive
knowledge. DeckIntegrationService::findFilterOptions() therefore throws
instead of degrading to [], unlike listLabels()/listParticipants(), where
an empty list only means an empty dropdown.
That also fixes an existing false positive of the same family:
loadStacksFor() stored [] when the request failed, so a single failed
fetch reported "Quell-Stapel und Ziel-Stapel nicht mehr vorhanden" for an
untouched board. Failed requests now store null and are read as "unknown".
The comparison itself lives in WorkflowRunner::findDeadFilters(), static
and side-effect free like cardMatchesFilters(), with unit tests -- notably
that an empty filter is never dead, which would otherwise flag every
unfiltered workflow.
Separately, the moved-card mail now names the board and both stacks.
DeckIntegrationService::describeTargets() resolves the titles at most once
per workflow run, and only when a card actually moved and the workflow
wants a mail; unreadable titles degrade to #<id> rather than costing the
user their notification.
Workflows were being switched off with "its board no longer exists or is
no longer available to you" for boards that were perfectly intact.
Deck does not read the session for permissions. PermissionService -- the
class behind every check, including the ones inside CardService::reorder()
-- takes the current user as a plain `private ?string $userId`, filled
from the app container's `userId` service (ISession::get('user_id'),
registered shared). Pimple resolves that once per process and caches it,
and ServerContainer caches Deck's app container just as long. In cron the
value is null whenever a Deck-owned job ran earlier in the same pass, and
otherwise the first workflow owner touched -- never the user being
impersonated. null fails every check, so Deck answered NoPermissionException
for an untouched board and findStackIds() read that as "the board is gone".
IUserSession::setUser() never had any effect on this path.
- DeckIntegrationService::beginUserContext()/endUserContext() pin
PermissionService (mandatory), CardService, BoardService and
ActivityManager (best effort) to the workflow owner, and restore them
afterwards. If PermissionService cannot be pinned, the runner skips that
user instead of acting under someone else's permissions.
- findStackIds() now takes the uid as an argument and is assembled from
pieces that cannot answer for the wrong user: BoardMapper and StackMapper
carry no user state, and getPermissions() is handed the uid explicitly.
It no longer goes through StackService::findAll().
- NoPermissionException is no longer treated as "board missing". Unknown
failures throw, which leaves the workflow enabled.
This also fixes the second half of the same defect: card moves silently
failed for every user except the first one processed in a cron pass.
Separately, RunWorkflowsJob is now a plain Job instead of a TimedJob and
runs on every cron pass. The workflow_deck_automation.interval config key
is gone. Time sensitivity is no longer merely declared but structurally
unreachable: JobList::add() leaves the column at its TIME_SENSITIVE
default, and the ratchet in setLastRun() only fires for TimedJob.
Deleting a board or stack left the workflow row pointing at nothing: Deck
keeps the orphaned cards readable until its DeleteCron purges them, so the
run kept failing on the move every few minutes, silently and forever.
- The controller now rejects boards and stacks that do not exist (or are not
the user's) on create and update, so no new broken row can be stored.
- The settings list marks affected workflows in red instead of showing a row
that looks healthy.
- The background job disables such a workflow and mails its owner once. The
mail ignores the notifyEmail flag: that one is about moved cards, this is a
notice that the automation stopped. It stays a one-off because a disabled
workflow is no longer picked up.
The check deliberately goes through StackService::findAll() rather than
BoardService::getUserBoards(): Deck injects the current user into BoardService
as a string frozen at construction, so in the job -- one process, many users,
a cached BoardService -- it would answer for the wrong user or for none, and
every workflow on the instance would have been disabled. findStackIds()
returns null only for a genuinely missing or forbidden board and throws for
anything else, so a Deck outage skips the workflow instead of killing it.
Job::start() calls setLastRun() before run() and clears `reserved_at` only
afterwards through setExecutionTime(), which is not in a finally block. Any
uncaught throwable therefore leaves the job reserved, and JobList::getNext()
skips reserved jobs until the reservation is 12 hours stale -- so a single
failing workflow takes the whole app offline for half a day, while last_run
still shows the start of that failed attempt.
Wrap both loops so a failure is logged with its exception and the remaining
users and workflows still run.
Ports the standalone due-date cron script into a proper Nextcloud 34
app with a personal-settings UI, per-user workflow configuration
(source/target stack, assigned-user and label filters, email
notification), a TimedJob background runner, and Gitea CI pipelines.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>