Add Deck workflow automation Nextcloud app
Lint info.xml / xml-lint (push) Successful in 30s
Lint PHP / php-lint (8.2) (push) Successful in 55s
Lint PHP / php-lint (8.3) (push) Successful in 44s
Lint PHP / php-lint (8.4) (push) Successful in 41s
PHPUnit / unit-tests (push) Failing after 41s

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>
This commit is contained in:
Patrick Niebeling
2026-08-13 10:13:05 +02:00
co-authored by Claude Sonnet 5
commit 300acde27e
34 changed files with 1918 additions and 0 deletions
+369
View File
@@ -0,0 +1,369 @@
<template>
<NcSettingsSection
name="Deck Workflow-Automatisierung"
description="Verschiebt überfällige Karten aus einem Deck-Stapel automatisch in einen anderen Stapel. Ein Hintergrundjob prüft die Regeln alle paar Minuten.">
<NcNoteCard v-if="loadError" type="error">
{{ loadError }}
</NcNoteCard>
<div v-if="loading" class="wfda-loading">
<NcLoadingIcon :size="32" />
</div>
<template v-else>
<table v-if="workflows.length" class="wfda-table">
<thead>
<tr>
<th>Titel</th>
<th>Von Stapel</th>
<th>Nach Stapel</th>
<th>Filter</th>
<th>E-Mail</th>
<th>Aktiv</th>
<th />
</tr>
</thead>
<tbody>
<tr v-for="workflow in workflows" :key="workflow.id">
<td>{{ workflow.title }}</td>
<td>{{ stackLabel(workflow.boardId, workflow.sourceStackId) }}</td>
<td>{{ stackLabel(workflow.boardId, workflow.targetStackId) }}</td>
<td>
<span v-if="!workflow.filterUserIds.length && !workflow.filterLabelIds.length"></span>
<span v-else>
<span v-if="workflow.filterUserIds.length">{{ workflow.filterUserIds.length }} Benutzer</span>
<span v-if="workflow.filterUserIds.length && workflow.filterLabelIds.length">, </span>
<span v-if="workflow.filterLabelIds.length">{{ workflow.filterLabelIds.length }} Label</span>
</span>
</td>
<td>{{ workflow.notifyEmail ? 'Ja' : 'Nein' }}</td>
<td>{{ workflow.enabled ? 'Ja' : 'Nein' }}</td>
<td class="wfda-actions">
<NcButton type="tertiary" @click="editWorkflow(workflow)">
Bearbeiten
</NcButton>
<NcButton type="tertiary" @click="removeWorkflow(workflow)">
Löschen
</NcButton>
</td>
</tr>
</tbody>
</table>
<p v-else class="wfda-empty">
Noch keine Workflows angelegt.
</p>
<NcButton v-if="!showForm" type="primary" class="wfda-add-button" @click="startCreate">
Workflow hinzufügen
</NcButton>
<form v-if="showForm" class="wfda-form" @submit.prevent="save">
<NcTextField
:value.sync="form.title"
label="Titel"
required />
<NcSelect
v-model="form.boardId"
label="Board"
:options="boardOptions"
:reduce="option => option.value"
placeholder="Board wählen"
@update:model-value="onBoardChange" />
<NcSelect
v-model="form.sourceStackId"
label="Quell-Stapel"
:options="stackOptions"
:reduce="option => option.value"
:disabled="!form.boardId"
placeholder="Quell-Stapel wählen" />
<NcSelect
v-model="form.targetStackId"
label="Ziel-Stapel"
:options="stackOptions"
:reduce="option => option.value"
:disabled="!form.boardId"
placeholder="Ziel-Stapel wählen" />
<NcSelect
v-model="form.filterUserIds"
label="Nur für zugewiesene Benutzer (optional)"
:options="participantOptions"
:reduce="option => option.value"
:disabled="!form.boardId"
multiple
placeholder="Alle Benutzer" />
<NcSelect
v-model="form.filterLabelIds"
label="Nur mit Label (optional)"
:options="labelOptions"
:reduce="option => option.value"
:disabled="!form.boardId"
multiple
placeholder="Alle Labels" />
<NcCheckboxRadioSwitch :checked.sync="form.notifyEmail">
Benachrichtigungs-E-Mail an mich senden
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch :checked.sync="form.enabled">
Workflow aktiv
</NcCheckboxRadioSwitch>
<NcNoteCard v-if="formError" type="error">
{{ formError }}
</NcNoteCard>
<div class="wfda-form-actions">
<NcButton type="primary" native-type="submit" :disabled="saving">
Speichern
</NcButton>
<NcButton type="tertiary" :disabled="saving" @click="cancelForm">
Abbrechen
</NcButton>
</div>
</form>
</template>
</NcSettingsSection>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import NcSettingsSection from '@nextcloud/vue/dist/Components/NcSettingsSection.js'
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js'
import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js'
import {
createWorkflow,
deleteWorkflow,
fetchBoards,
fetchLabels,
fetchParticipants,
fetchStacks,
fetchWorkflows,
updateWorkflow,
} from './api.js'
const loading = ref(true)
const loadError = ref('')
const workflows = ref([])
const boards = ref([])
const stacksByBoard = reactive({})
const showForm = ref(false)
const saving = ref(false)
const formError = ref('')
const editingId = ref(null)
const stackOptions = ref([])
const labelOptions = ref([])
const participantOptions = ref([])
const boardOptions = computed(() => boards.value.map((board) => ({ value: board.id, label: board.title })))
const form = reactive(emptyForm())
function emptyForm() {
return {
title: '',
boardId: null,
sourceStackId: null,
targetStackId: null,
filterUserIds: [],
filterLabelIds: [],
notifyEmail: false,
enabled: true,
}
}
function stackLabel(boardId, stackId) {
const stacks = stacksByBoard[boardId]
if (!stacks) {
return `#${stackId}`
}
const stack = stacks.find((entry) => entry.id === stackId)
return stack ? stack.title : `#${stackId}`
}
async function loadStacksFor(boardId) {
if (!boardId || stacksByBoard[boardId]) {
return
}
try {
stacksByBoard[boardId] = await fetchStacks(boardId)
} catch (e) {
stacksByBoard[boardId] = []
}
}
async function loadAll() {
loading.value = true
loadError.value = ''
try {
const [loadedWorkflows, loadedBoards] = await Promise.all([fetchWorkflows(), fetchBoards()])
workflows.value = loadedWorkflows
boards.value = loadedBoards
await Promise.all(loadedWorkflows.map((workflow) => loadStacksFor(workflow.boardId)))
} catch (e) {
loadError.value = 'Konnte Workflows oder Deck-Boards nicht laden. Ist die Deck-App aktiviert?'
} finally {
loading.value = false
}
}
async function onBoardChange(boardId) {
form.sourceStackId = null
form.targetStackId = null
form.filterUserIds = []
form.filterLabelIds = []
stackOptions.value = []
labelOptions.value = []
participantOptions.value = []
if (!boardId) {
return
}
const [stacks, labels, participants] = await Promise.all([
fetchStacks(boardId),
fetchLabels(boardId),
fetchParticipants(boardId),
])
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 }))
}
function startCreate() {
Object.assign(form, emptyForm())
editingId.value = null
formError.value = ''
stackOptions.value = []
labelOptions.value = []
participantOptions.value = []
showForm.value = true
}
async function editWorkflow(workflow) {
Object.assign(form, {
title: workflow.title,
boardId: workflow.boardId,
sourceStackId: workflow.sourceStackId,
targetStackId: workflow.targetStackId,
filterUserIds: [...workflow.filterUserIds],
filterLabelIds: [...workflow.filterLabelIds],
notifyEmail: workflow.notifyEmail,
enabled: workflow.enabled,
})
editingId.value = workflow.id
formError.value = ''
showForm.value = true
await onBoardChange(workflow.boardId)
}
function cancelForm() {
showForm.value = false
formError.value = ''
}
async function save() {
formError.value = ''
if (!form.title.trim()) {
formError.value = 'Bitte einen Titel angeben.'
return
}
if (!form.boardId || !form.sourceStackId || !form.targetStackId) {
formError.value = 'Bitte Board, Quell- und Ziel-Stapel wählen.'
return
}
if (form.sourceStackId === form.targetStackId) {
formError.value = 'Quell- und Ziel-Stapel müssen sich unterscheiden.'
return
}
saving.value = true
try {
if (editingId.value) {
await updateWorkflow(editingId.value, form)
} else {
await createWorkflow(form)
}
showForm.value = false
await loadAll()
} catch (e) {
formError.value = e?.response?.data?.ocs?.meta?.message || 'Speichern fehlgeschlagen.'
} finally {
saving.value = false
}
}
async function removeWorkflow(workflow) {
// eslint-disable-next-line no-alert
if (!window.confirm(`Workflow "${workflow.title}" wirklich löschen?`)) {
return
}
await deleteWorkflow(workflow.id)
await loadAll()
}
onMounted(loadAll)
</script>
<style scoped>
.wfda-loading {
display: flex;
justify-content: center;
padding: 20px;
}
.wfda-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 16px;
}
.wfda-table th,
.wfda-table td {
text-align: left;
padding: 8px 12px;
border-bottom: 1px solid var(--color-border);
}
.wfda-actions {
display: flex;
gap: 4px;
justify-content: flex-end;
}
.wfda-empty {
color: var(--color-text-maxcontrast);
margin-bottom: 16px;
}
.wfda-add-button {
margin-bottom: 16px;
}
.wfda-form {
display: flex;
flex-direction: column;
gap: 12px;
max-width: 480px;
padding: 16px;
border: 1px solid var(--color-border);
border-radius: var(--border-radius-large);
}
.wfda-form-actions {
display: flex;
gap: 8px;
}
</style>