Surface dead filters, and name board and stacks in the moved-card mail
Build package / package (push) Successful in 55s
Build package / php-lint (8.2) (push) Successful in 41s
Build package / php-lint (8.3) (push) Successful in 34s
Build package / php-lint (8.4) (push) Successful in 40s
Build package / xml-lint (push) Successful in 11s
Build package / unit-tests (push) Successful in 42s
Build package / package (push) Successful in 55s
Build package / php-lint (8.2) (push) Successful in 41s
Build package / php-lint (8.3) (push) Successful in 34s
Build package / php-lint (8.4) (push) Successful in 40s
Build package / xml-lint (push) Successful in 11s
Build package / unit-tests (push) Successful in 42s
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.
This commit is contained in:
+83
-23
@@ -159,7 +159,10 @@ const loading = ref(true)
|
||||
const loadError = ref('')
|
||||
const workflows = ref([])
|
||||
const boards = ref([])
|
||||
// Per board, and `null` whenever the request failed — see loadStacksFor().
|
||||
const stacksByBoard = reactive({})
|
||||
const labelsByBoard = reactive({})
|
||||
const participantsByBoard = reactive({})
|
||||
|
||||
const showForm = ref(false)
|
||||
const saving = ref(false)
|
||||
@@ -181,9 +184,15 @@ const sourceStackOptions = computed(() => stackOptions.value.filter((option) =>
|
||||
const targetStackOptions = computed(() => stackOptions.value.filter((option) => option.value !== form.sourceStackId))
|
||||
|
||||
/**
|
||||
* Workflows whose board or stacks were deleted in Deck, keyed by id. The
|
||||
* background job switches these off by itself, but a row that just stopped
|
||||
* working needs to say so instead of looking healthy.
|
||||
* Workflows that stopped working, keyed by id. Two different kinds:
|
||||
*
|
||||
* - Board or stacks deleted in Deck. The background job switches these off
|
||||
* by itself, but a row that just stopped working needs to say so instead
|
||||
* of looking healthy.
|
||||
* - Every entry of a filter deleted from the board. Nothing switches those
|
||||
* off — deliberately, since one deleted label out of three is harmless —
|
||||
* yet a filter that cannot match a single card any more is otherwise
|
||||
* completely invisible: the workflow keeps running and moves nothing.
|
||||
*/
|
||||
const workflowIssues = computed(() => {
|
||||
const issues = {}
|
||||
@@ -195,25 +204,49 @@ const workflowIssues = computed(() => {
|
||||
}
|
||||
|
||||
const stacks = stacksByBoard[workflow.boardId]
|
||||
if (!stacks) {
|
||||
continue
|
||||
if (stacks) {
|
||||
const missing = []
|
||||
if (!stacks.some((stack) => stack.id === workflow.sourceStackId)) {
|
||||
missing.push('Quell-Stapel')
|
||||
}
|
||||
if (!stacks.some((stack) => stack.id === workflow.targetStackId)) {
|
||||
missing.push('Ziel-Stapel')
|
||||
}
|
||||
if (missing.length) {
|
||||
issues[workflow.id] = `${missing.join(' und ')} nicht mehr vorhanden`
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const missing = []
|
||||
if (!stacks.some((stack) => stack.id === workflow.sourceStackId)) {
|
||||
missing.push('Quell-Stapel')
|
||||
const dead = []
|
||||
if (isDeadFilter(workflow.filterLabelIds, labelsByBoard[workflow.boardId], 'id')) {
|
||||
dead.push('Label-Filter')
|
||||
}
|
||||
if (!stacks.some((stack) => stack.id === workflow.targetStackId)) {
|
||||
missing.push('Ziel-Stapel')
|
||||
if (isDeadFilter(workflow.filterUserIds, participantsByBoard[workflow.boardId], 'uid')) {
|
||||
dead.push('Benutzer-Filter')
|
||||
}
|
||||
if (missing.length) {
|
||||
issues[workflow.id] = `${missing.join(' und ')} nicht mehr vorhanden`
|
||||
if (dead.length > 1) {
|
||||
issues[workflow.id] = `${dead.join(' und ')} treffen keine Karte mehr`
|
||||
} else if (dead.length) {
|
||||
issues[workflow.id] = `${dead[0]} trifft keine Karte mehr`
|
||||
}
|
||||
}
|
||||
|
||||
return issues
|
||||
})
|
||||
|
||||
/**
|
||||
* True only on positive knowledge: the filter has entries, the board's list
|
||||
* was actually loaded, and not one entry is still on it. A failed request
|
||||
* (null) says nothing and must not turn a healthy row red.
|
||||
*/
|
||||
function isDeadFilter(selected, available, key) {
|
||||
if (!selected.length || !available) {
|
||||
return false
|
||||
}
|
||||
return !selected.some((value) => available.some((entry) => entry[key] === value))
|
||||
}
|
||||
|
||||
const form = reactive(emptyForm())
|
||||
|
||||
function emptyForm() {
|
||||
@@ -242,11 +275,27 @@ async function loadStacksFor(boardId) {
|
||||
if (!boardId || stacksByBoard[boardId]) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
stacksByBoard[boardId] = await fetchStacks(boardId)
|
||||
} catch (e) {
|
||||
stacksByBoard[boardId] = []
|
||||
// `null`, not `[]` — "could not ask Deck" has to stay distinguishable
|
||||
// from "this board has none", or a single failed request reports every
|
||||
// stack of the board as deleted.
|
||||
stacksByBoard[boardId] = await fetchStacks(boardId).catch(() => null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Labels and participants of a board — needed only to judge whether a stored
|
||||
* filter can still match anything, so this is fetched just for the boards of
|
||||
* workflows that actually use filters.
|
||||
*/
|
||||
async function loadFiltersFor(boardId) {
|
||||
if (!boardId || labelsByBoard[boardId]) {
|
||||
return
|
||||
}
|
||||
const [labels, participants] = await Promise.all([
|
||||
fetchLabels(boardId).catch(() => null),
|
||||
fetchParticipants(boardId).catch(() => null),
|
||||
])
|
||||
labelsByBoard[boardId] = labels
|
||||
participantsByBoard[boardId] = participants
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
@@ -256,7 +305,13 @@ async function loadAll() {
|
||||
const [loadedWorkflows, loadedBoards] = await Promise.all([fetchWorkflows(), fetchBoards()])
|
||||
workflows.value = loadedWorkflows
|
||||
boards.value = loadedBoards
|
||||
await Promise.all(loadedWorkflows.map((workflow) => loadStacksFor(workflow.boardId)))
|
||||
await Promise.all(loadedWorkflows.flatMap((workflow) => {
|
||||
const pending = [loadStacksFor(workflow.boardId)]
|
||||
if (workflow.filterLabelIds.length || workflow.filterUserIds.length) {
|
||||
pending.push(loadFiltersFor(workflow.boardId))
|
||||
}
|
||||
return pending
|
||||
}))
|
||||
} catch (e) {
|
||||
loadError.value = 'Konnte Workflows oder Deck-Boards nicht laden. Ist die Deck-App aktiviert?'
|
||||
} finally {
|
||||
@@ -281,15 +336,20 @@ async function loadBoardOptions(boardId) {
|
||||
|
||||
// One failing endpoint must not take the other two dropdowns down with it.
|
||||
const [stacks, labels, participants] = await Promise.all([
|
||||
fetchStacks(boardId).catch(() => []),
|
||||
fetchLabels(boardId).catch(() => []),
|
||||
fetchParticipants(boardId).catch(() => []),
|
||||
fetchStacks(boardId).catch(() => null),
|
||||
fetchLabels(boardId).catch(() => null),
|
||||
fetchParticipants(boardId).catch(() => null),
|
||||
])
|
||||
|
||||
// Feed the overview's caches too, so its red markers agree with what the
|
||||
// form just loaded. `null` propagates on purpose: it means "unknown".
|
||||
stacksByBoard[boardId] = stacks
|
||||
stackOptions.value = stacks.map((stack) => ({ value: stack.id, label: stack.title }))
|
||||
labelOptions.value = labels.map((label) => ({ value: label.id, label: label.title }))
|
||||
participantOptions.value = participants.map((participant) => ({ value: participant.uid, label: participant.displayName }))
|
||||
labelsByBoard[boardId] = labels
|
||||
participantsByBoard[boardId] = participants
|
||||
|
||||
stackOptions.value = (stacks ?? []).map((stack) => ({ value: stack.id, label: stack.title }))
|
||||
labelOptions.value = (labels ?? []).map((label) => ({ value: label.id, label: label.title }))
|
||||
participantOptions.value = (participants ?? []).map((participant) => ({ value: participant.uid, label: participant.displayName }))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user