Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bd3181f10 | ||
|
|
7612344726 | ||
|
|
35903772eb | ||
|
|
5083607ac4 | ||
|
|
c7baa8c67f | ||
|
|
e5b5f25748 | ||
|
|
ebf48b0707 | ||
|
|
30e644a001 | ||
|
|
860e8b7bbb | ||
|
|
1ffc0765ea | ||
|
|
578b235f08 | ||
|
|
27717205cd | ||
|
|
ed684cca4f | ||
|
|
0904292c73 | ||
|
|
e84123654e | ||
|
|
ada16cb101 | ||
|
|
7c7c52cb1c | ||
|
|
dfec88d396 | ||
|
|
28b4edc811 | ||
|
|
0e66319a06 | ||
|
|
43648f3728 | ||
|
|
ce749e63a4 | ||
|
|
b203ba35f8 | ||
|
|
ef973af4b5 | ||
|
|
a95d5e2d8a | ||
|
|
2d2f3ca19c | ||
|
|
a6dde35249 | ||
|
|
7ca728e79c | ||
|
|
be6faa7b42 | ||
|
|
131ef2e938 | ||
|
|
b4057809ed |
No files matched your search
@@ -0,0 +1,84 @@
|
||||
name: Publish to App Store
|
||||
|
||||
# Manual only: pushes an already published v* release to apps.nextcloud.com.
|
||||
# It does not build anything - it signs the exact tarball build-main.yml
|
||||
# attached to the Gitea release, because the App Store downloads the file from
|
||||
# that URL and checks the signature against it. That release only exists if
|
||||
# the checks were green, so this workflow needs no `needs:` of its own.
|
||||
#
|
||||
# Secrets:
|
||||
# APPSTORE_PRIVATE_KEY workflow_deck_automation.key (PEM, the key the App
|
||||
# Store certificate was issued for)
|
||||
# APPSTORE_TOKEN API token from https://apps.nextcloud.com/account/token
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release-Tag, z. B. v35.2.3'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
appstore:
|
||||
runs-on: gitea-runner-server03
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
TAG: ${{ inputs.tag }}
|
||||
DOWNLOAD_URL: ${{ gitea.server_url }}/${{ gitea.repository }}/releases/download/${{ inputs.tag }}/workflow_deck_automation.tar.gz
|
||||
steps:
|
||||
- name: Check tag
|
||||
run: |
|
||||
# latest-main is rebuilt on every push, so its tarball (and signature)
|
||||
# would not stay valid - only real version tags go to the store.
|
||||
if ! printf '%s' "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "::error::'$TAG' is not a version tag (vX.Y.Z)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Download release tarball
|
||||
run: |
|
||||
# Anonymous on purpose: the App Store fetches this URL without
|
||||
# credentials, so a private or missing release has to fail here.
|
||||
curl -fsSL "$DOWNLOAD_URL" -o workflow_deck_automation.tar.gz
|
||||
|
||||
- name: Check version in tarball
|
||||
run: |
|
||||
VERSION=$(tar -xzOf workflow_deck_automation.tar.gz workflow_deck_automation/appinfo/info.xml \
|
||||
| sed -n 's:.*<version>\(.*\)</version>.*:\1:p')
|
||||
if [ "v$VERSION" != "$TAG" ]; then
|
||||
echo "::error::info.xml says version '$VERSION', tag is '$TAG'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Sign and upload
|
||||
env:
|
||||
PRIVATE_KEY: ${{ secrets.APPSTORE_PRIVATE_KEY }}
|
||||
APPSTORE_TOKEN: ${{ secrets.APPSTORE_TOKEN }}
|
||||
run: |
|
||||
set -e
|
||||
if [ -z "$PRIVATE_KEY" ] || [ -z "$APPSTORE_TOKEN" ]; then
|
||||
echo "::error::APPSTORE_PRIVATE_KEY and APPSTORE_TOKEN must be set"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
KEY_FILE=$(mktemp)
|
||||
trap 'rm -f "$KEY_FILE"' EXIT
|
||||
chmod 600 "$KEY_FILE"
|
||||
printf '%s\n' "$PRIVATE_KEY" > "$KEY_FILE"
|
||||
|
||||
# -A keeps the base64 on one line, so it can go into the JSON as is.
|
||||
SIGNATURE=$(openssl dgst -sha512 -sign "$KEY_FILE" workflow_deck_automation.tar.gz | openssl base64 -A)
|
||||
|
||||
STATUS=$(curl -sS -o response.json -w '%{http_code}' -X POST \
|
||||
-H "Authorization: Token $APPSTORE_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"download\":\"$DOWNLOAD_URL\",\"signature\":\"$SIGNATURE\",\"nightly\":false}" \
|
||||
https://apps.nextcloud.com/api/v1/apps/releases)
|
||||
|
||||
cat response.json; echo
|
||||
# 201 = new release, 200 = existing release replaced
|
||||
case "$STATUS" in
|
||||
200|201) echo "App Store accepted $TAG (HTTP $STATUS)" ;;
|
||||
*) echo "::error::App Store answered HTTP $STATUS"; exit 1 ;;
|
||||
esac
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: gitea-runner-server03
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['8.2', '8.3', '8.4']
|
||||
php-version: ['8.4', '8.5']
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
|
||||
- uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.3'
|
||||
php-version: '8.4'
|
||||
extensions: sqlite, pdo_sqlite
|
||||
coverage: none
|
||||
|
||||
|
||||
@@ -1,157 +1,169 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
Leitfaden für Claude Code (claude.ai/code) in diesem Repository.
|
||||
|
||||
## What this is
|
||||
## Worum es geht
|
||||
|
||||
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.
|
||||
Nextcloud-App `workflow_deck_automation` (Namespace `WorkflowDeckAutomation`) für **Nextcloud 35** (`main`, `min-version="35" max-version="35"`). Jeder Nutzer legt in seinen persönlichen Einstellungen „Workflows“ an, die überfällige [Deck](https://github.com/nextcloud/deck)-Karten von einem Stack in einen anderen verschieben, optional mit Mail. Es gibt bewusst **keine Kartenfilter**: ein Workflow verschiebt *jede* überfällige Karte seines Quell-Stacks. Ein Hintergrund-`Job` wertet alle aktiven Workflows bei jedem Cron-Lauf aus.
|
||||
|
||||
## Non-negotiable architectural constraint
|
||||
## Harte Vorgabe: keine HTTP/OCS-Aufrufe gegen Deck
|
||||
|
||||
**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.
|
||||
Alle Lesezugriffe (Boards, Stacks, Karten) und das Verschieben laufen über Decks interne PHP-Klassen (`OCA\Deck\Service\*`, `OCA\Deck\Db\*`), in-process per `\OCP\Server::get()`. Das ist eine ausdrückliche, wiederholte Anforderung des Nutzers – nicht auf `IClientService`/`curl` gegen `/ocs/...` „umbauen“.
|
||||
|
||||
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.
|
||||
**Nur `lib/Service/DeckIntegrationService.php` referenziert `OCA\Deck\*`.** Neue Deck-Daten → diese Klasse erweitern. Jeder Aufruf dort ist abgesichert (`class_exists()`, `IAppManager::isEnabledForUser('deck', …)`, try/catch → `DeckUnavailableException`), weil `OCA\Deck\*` keine stabile, dokumentierte API ist (keine `@since`-Marker).
|
||||
|
||||
## Per-user impersonation in the background job
|
||||
## Impersonation im Hintergrundjob – Decks eingefrorene `$userId`
|
||||
|
||||
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).
|
||||
`WorkflowRunner` gruppiert Workflows nach Owner und impersoniert jeden nacheinander, in zwei Schritten (beide im `finally` via `clearImpersonation()` zurückgesetzt):
|
||||
|
||||
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`.
|
||||
1. `IUserSession::setUser()` + `IRootFolder::getUserFolder()` – für die wenigen Dienste, die die Session lesen. Nebensächlich.
|
||||
2. `DeckIntegrationService::beginUserContext()` – **der tragende Teil.**
|
||||
|
||||
**Not everything in Deck reads the live session, though.** `BoardService` takes the current user as `private ?string $userId` — a plain string frozen when the container builds it. `getUserBoards()` uses exactly that, so in the background job (one process, many users, a container-cached `BoardService`) it answers for whoever happened to be impersonated at first resolution, or for `null`. `listBoardsForCurrentUser()` is therefore **request-only**; the job asks `findStackIds()` instead, which goes through `StackService::findAll()` → `PermissionService` → live session. Before using any other `OCA\Deck\*` service from the runner, check which of the two kinds it is — the failure mode is silent and user-crossing, not an error.
|
||||
**Deck liest für Berechtigungen nie die Session.** `PermissionService` (hinter jeder Prüfung, auch in `CardService::reorder()`), `CardService`, `BoardService` und `ActivityManager` halten `private ?string $userId` aus dem `userId`-Service des Deck-Containers – *shared*, also einmal pro Prozess aufgelöst. Im Cron ist das nie der impersonierte Nutzer, sondern `null` (wenn vorher ein Deck-Job lief) oder der erste Owner. `null` lässt jede Prüfung mit `NoPermissionException` scheitern.
|
||||
|
||||
## Workflows are auto-disabled when their Deck targets vanish
|
||||
`beginUserContext()` überschreibt daher das private Feld per Reflection und `endUserContext()` stellt es wieder her. `PermissionService` ist Pflicht: klappt das Pinnen nicht, wirft die Methode und der Runner **überspringt den Nutzer komplett**. Die anderen drei sind best effort (nur Activity-Zuordnung/Unread-Zähler).
|
||||
|
||||
`WorkflowRunner::findBrokenTarget()` checks board and stacks before every run and, if something is gone, switches the workflow off (`enabled = false`) and sends a one-off mail. Two invariants hold this together and must survive refactors:
|
||||
**Regel:** Bevor ein weiterer `OCA\Deck\*`-Dienst im Runner genutzt wird, prüfen, ob er eine eingefrorene `$userId` trägt – der Fehler ist still und nutzerübergreifend. Bevorzugt Mapper (QBMapper ohne Nutzerzustand) oder Methoden mit explizitem uid-Parameter verwenden. Deshalb wird `BoardService::getUserBoards()` nicht verwendet.
|
||||
|
||||
- **Only positive knowledge disables.** `DeckIntegrationService::findStackIds()` returns `null` only for "board missing/not readable" (`DoesNotExistException`, Deck's `NoPermissionException`/`NotFoundException`) and *throws* for everything else. The runner skips the workflow on a throw. If you make that method degrade to `[]`/`null` on generic errors, a Deck outage will disable every workflow on the instance and mail every user about it.
|
||||
- **The mail ignores the `notifyEmail` flag** — that flag is about moved cards; this is a notice that the user's configuration stopped working. It stays a one-off simply because a disabled workflow is not picked up by `findAllEnabled()` again.
|
||||
Die Fälligkeitslogik `WorkflowRunner::isOverdue()` und die Empfängerregel `NotificationPolicy::shouldNotify()` sind bewusst `static` und seiteneffektfrei, also ohne Deck unit-testbar (`tests/Unit/Service/`). Das sind die einzigen getesteten Teile der App. Neue reine Logik in dieser Form halten.
|
||||
|
||||
Archived boards deliberately do *not* trigger this (they stay readable, so `findStackIds()` succeeds), even though they're filtered out of the settings dropdowns.
|
||||
## Workflows werden deaktiviert, wenn Board/Stack verschwinden
|
||||
|
||||
## Job interval comes from config.php
|
||||
`WorkflowRunner::findBrokenTarget()` prüft vor jedem Lauf Board und Stacks; fehlt etwas, wird der Workflow abgeschaltet (`enabled = false`) und einmalig gemailt. Invarianten:
|
||||
|
||||
`RunWorkflowsJob` reads `workflow_deck_automation.interval` (seconds, default 300, clamped to a 60s minimum) via `IConfig::getSystemValueInt()` in its constructor. That works *because* `TimedJob` re-reads `$this->interval` on every cron pass from the freshly constructed job — so editing `config.php` takes effect immediately, with no `occ` command and no re-registration. Don't move this into `appinfo/info.xml` or a stored app config; the constants (`INTERVAL_CONFIG_KEY`, `DEFAULT_INTERVAL`, `MINIMUM_INTERVAL`) on the job are the single source of truth and are referenced from the README.
|
||||
- **Nur positives Wissen deaktiviert.** `findStackIds()` liefert `null` nur bei „Board-Zeile weg“ (`DoesNotExistException`, Decks `NotFoundException`), „Board im Papierkorb“ (`deleted_at > 0`) oder „uid hat keine Leserechte“ – alles andere *wirft*, und der Runner überspringt den Workflow. Degradiert die Methode auf `[]`/`null`, deaktiviert ein Deck-Ausfall alle Workflows der Instanz und mailt alle Nutzer an.
|
||||
- **`findStackIds()` fragt nie einen Deck-Dienst, *wer* fragt.** Sie bekommt die uid als Argument und nutzt nur `BoardMapper`/`StackMapper` plus `PermissionService::getPermissions($boardId, $userId)` mit expliziter uid. Nicht zu `StackService::findAll($boardId)` „vereinfachen“ – genau das prüfte gegen die eingefrorene `null`-uid, warf `NoPermissionException`, wurde als „Board weg“ gelesen und verschickte Deaktivierungsmails für intakte Boards.
|
||||
- **`NoPermissionException` beweist nichts** und steht deshalb bewusst nicht in `isMissing()`.
|
||||
- **Die Deaktivierungsmail ignoriert `notifyMode` und `notifyChannel`** (die betreffen verschobene Karten) und geht immer nur an den Owner, zusätzlich als Nextcloud-Benachrichtigung, falls die Notifications-App aktiv ist. Sie bleibt einmalig, weil `findAllEnabled()` deaktivierte Workflows nicht mehr lädt.
|
||||
|
||||
**The job must stay `IJob::TIME_SENSITIVE`.** It was `TIME_INSENSITIVE` at first, which looked harmless and was not: `OC\Core\Service\CronService::runCli()` reads `maintenance_window_start` (default `100`, i.e. unset) and, whenever the current UTC hour is outside `[start, start+4]`, calls `jobList->getNext($onlyTimeSensitive = true)` — time-insensitive jobs are then skipped for the other 20 hours of the day. On an instance with a maintenance window configured (Nextcloud's admin overview nags admins into setting one) the job simply never ran, showing `last_run = 1970-01-01` in `occ background-job:list` indefinitely. Note this gate only exists in `runCli()`, so it doesn't apply to ajax/webcron mode, and passing job classes explicitly (`occ background-job:worker '<class>'`) bypasses it — which is exactly why manual testing can look fine while cron does nothing.
|
||||
Archivierte Boards lösen das nicht aus (sie bleiben lesbar), werden aber in den Dropdowns ausgeblendet.
|
||||
|
||||
**Changing the sensitivity in code does not fix an already-registered job.** `getNext()` filters on the `time_sensitive` *column* of `oc_jobs`, and `JobList::setLastRun()` is a one-way ratchet:
|
||||
## Ein Workflow gehört dem Board-Owner, nicht seinem Ersteller
|
||||
|
||||
```php
|
||||
if ($job instanceof TimedJob && !$job->isTimeSensitive()) {
|
||||
$query->set('time_sensitive', $query->createNamedParameter(IJob::TIME_INSENSITIVE));
|
||||
}
|
||||
```
|
||||
Wird ein Board in Deck übertragen, wandern seine Workflows mit.
|
||||
|
||||
There is no `else`. Once a job has run while declaring itself insensitive, the row is stamped `TIME_INSENSITIVE` for good — and nothing resets it: `JobList::add()` only writes the column in its *insert* branch, and re-registering an existing job (app update, `app:enable`) takes the *update* branch, which leaves it alone. So after deploying the `TIME_SENSITIVE` change, an existing instance still needs its row fixed once:
|
||||
- **„Meine Boards“** = `listOwnedBoards($uid)` → `BoardMapper::findAllByOwner($uid)` (keine geteilten Boards, keine eingefrorene uid). Diese Mapper-Methode filtert weder Papierkorb noch Archiv: Papierkorb-Boards werden verworfen, archivierte mit `archived: true` behalten (Workflows sichtbar, aber nicht im Dropdown, von `validate()` abgelehnt). Wirft statt `[]` zurückzugeben – `[]` würde alle Workflows des Nutzers verstecken.
|
||||
- **Auflisten/Bearbeiten/Löschen** nur über `board_id IN (listOwnedBoards)` (`WorkflowMapper::findAllForBoards()`, `WorkflowController::findOwnWorkflow()`). `user_id` spielt keine Rolle – keine `user_id`-Abfragen im Controller wieder einführen.
|
||||
- **Anlegen/Speichern** validiert gegen dieselbe Liste und setzt `user_id` auf den aktuellen Nutzer.
|
||||
- **Runner:** `followBoardOwner()` → `findBoardOwner()` (`BoardMapper::find()`) schreibt einen abweichenden Owner in `user_id` zurück. Lauf und Mails gehen also an den aktuellen Owner. Ein verschwundenes Board behält den letzten Owner, damit `findBrokenTarget()` ihn anmailen kann.
|
||||
|
||||
`user_id` heißt also nur „als wer läuft der Job / wer bekommt Mail“.
|
||||
|
||||
## Mails über verschobene Karten folgen Decks Benachrichtigungseinstellung
|
||||
|
||||
`notify_mode` (`NotificationPolicy::MODES`) ersetzt seit `Version1002…`/`Version1003…` das frühere `notify_email` (`true` → `owner`):
|
||||
|
||||
- `off`: keine Mail. `owner`: nur der Board-Owner, ohne Rücksicht auf seine Deck-Einstellung (altes Verhalten).
|
||||
- `board`: alle Board-Berechtigten, gefiltert nach deren Deck-Nutzereinstellung `deck` / `board:<id>:notify-due` (`off`/`assigned`/`all`, Default `assigned`). `NotificationPolicy::shouldNotify()` bildet Decks Regel aus `NotificationHelper::sendCardDuedate()` nach und ist reine, getestete Logik. Dazu gehört: Der Owner eines ungeteilten Boards wird außer bei `off` immer benachrichtigt.
|
||||
- `DeckIntegrationService::findNotificationRecipients()` nutzt nur uid-explizite Bausteine: `PermissionService::findUsers($boardId, true)` (Owner + ACL, löst Gruppen/Circles auf, `true` umgeht den Prozess-Cache), `AssignmentMapper::isUserAssigned()` (auch Gruppen-/Circle-Zuweisungen) und `IConfig`. `FederatedUser` wird übersprungen. Bei jedem Fehler wirft die Methode, und der Runner verschickt dann **keine** Mail, statt an eine falsch aufgelöste Empfängerliste zu schicken.
|
||||
- Der Runner sammelt verschobene Karten in `$pendingNotices` und verschickt **nach** der Nutzer-Schleife (also ohne Impersonation) je nach `notify_channel` des Workflows eine Sammelmail pro Empfänger und Lauf und/oder eine Nextcloud-Benachrichtigung pro Karte.
|
||||
|
||||
## Nextcloud-Benachrichtigungen sind ein zweiter Kanal, keine zweite Empfängerregel
|
||||
|
||||
`notify_channel` (`NotificationPolicy::CHANNELS`: `mail`/`notification`/`both`, Default und Migrationswert `mail`, `Version1004…`) sagt nur, *wie* die über `notify_mode` ermittelten Empfänger informiert werden. `NotificationPolicy::sendsMail()`/`sendsNotification()` sind reine, getestete Logik. Unbekannte Werte zählen als `mail`.
|
||||
|
||||
- **`NotificationPusher`** legt über `OCP\Notification\IManager` eine Benachrichtigung **pro Karte** an (Objekt `card`/Karten-ID, Subject `card_moved`) und räumt vorher per `markProcessed()` eine ältere zur selben Karte weg. Gespeichert werden nur Rohwerte (Titel, IDs), keine Texte.
|
||||
- **`Notification\Notifier`** (per `registerNotifierService()` in `Application::register()`) baut daraus deutsche Texte und ignoriert `$languageCode`. Titel sind Nutzereingaben und gehen nur als Rich-Objekte (`deck-card`, `deck-board`, `highlight`) in den Text, nie ins Template, sonst würde ein `{…}` im Titel als Platzhalter gelesen. Der Notifier fragt **Deck nicht**: Er läuft beim Abruf durch irgendeinen Nutzer, und Deck-Dienste hätten dort die eingefrorene uid.
|
||||
- **Ohne Notifications-App passiert nichts:** `NotificationPusher::isAvailableFor()` prüft `IAppManager::isEnabledForUser('notifications', $user)` pro Empfänger und kehrt stumm zurück. Kein Fallback auf Mail, kein Fehler-Log. Die Einstellungsseite bekommt denselben Check als `data-notifications-available` und zeigt dann einen Hinweis.
|
||||
- Push aufs Handy/Desktop ist Sache der Notifications-App (Push-Proxy bzw. `notify_push`), nicht dieser App.
|
||||
|
||||
## Nur Deutsch, keine Übersetzungen
|
||||
|
||||
Die App wird nur intern genutzt und spricht ausschließlich Deutsch. Oberfläche, Controller-Fehlermeldungen (erscheinen im Formular), Sektionsname, Mails und Nextcloud-Benachrichtigungen sind direkt auf Deutsch geschrieben. Es gibt kein `IL10N` und keinen `l10n/`-Ordner. Nextclouds eigene Mail-Fußzeile wird per `addFooter('', 'de')` ebenfalls deutsch, sonst würde sie im Cron der Instanz-Standardsprache folgen. Log-Meldungen bleiben englisch. Neue nutzersichtbare Texte auf Deutsch schreiben, mit „du“ wie im Rest der App.
|
||||
|
||||
## Fälligkeit wird als UTC-Zeitpunkt verglichen
|
||||
|
||||
Decks `duedate` ist eine `DATETIME`-Spalte ohne Zeitzone mit UTC-Uhrzeit (Frontend sendet `toISOString()`). `Entity` baut daraus `new \DateTime($string)` in PHPs Standardzone, die Nextcloud auf UTC setzt. `WorkflowRunner::asStoredUtc()` legt die Uhrzeit trotzdem ausdrücklich auf UTC fest, damit das Ergebnis nicht von dieser Standardzone abhängt. Decks eigene Pfade `Card::getDaysUntilDue()` (tagesgenau) und `CardMapper::findOverdue()` (vergleicht mit dem `NOW()` der Datenbank, also Ortszeit) sind dafür ungeeignet und werden nicht verwendet.
|
||||
|
||||
## Kartenfilter wurden entfernt
|
||||
|
||||
Die früheren Filter (`filter_user_ids`/`filter_label_ids`) wurden auf Wunsch entfernt. `Version1001Date20260930120000` löscht die Spalten, und gefilterte Workflows laufen seitdem *ungefiltert* weiter (bewusste Entscheidung). Die Spalten müssen gelöscht bleiben: `Entity::fromRow()` ruft pro Spalte einen Setter auf, den `Workflow` nicht mehr hat. Mit entfernt wurden: Labels-/Participants-Endpunkte, `listLabels()`/`listParticipants()`/`findFilterOptions()` und `CardService::enrichCards()` in `getActiveCardsInStack()` (lief über `BoardService::find()` mit eingefrorener uid). Die Karten dort sind also **nicht angereichert** (keine `assignedUsers`/Labels).
|
||||
|
||||
## Der Job hat kein Intervall
|
||||
|
||||
`RunWorkflowsJob` erweitert `OCP\BackgroundJob\Job`, **nicht** `TimedJob`: ein Lauf pro Cron-Durchgang, kein Intervall, keine Konfiguration (der frühere `config.php`-Schlüssel `workflow_deck_automation.interval` ist seit v0.2.0 weg). Nicht wieder einführen.
|
||||
|
||||
**Zeitkritikalität entscheidet, ob der Job überhaupt läuft.** `CronService::runCli()` holt außerhalb des Wartungsfensters (`maintenance_window_start` … +4 h UTC) nur zeitkritische Jobs (`getNext($onlyTimeSensitive = true)`). Eine frühere Version war `TIME_INSENSITIVE` und lief auf Instanzen mit Wartungsfenster nie (`last_run = 1970-01-01`). Manuelle Tests (`background-job:worker '<Klasse>'`, Ajax/Webcron) umgehen dieses Gate, deshalb wirkt dort alles normal.
|
||||
|
||||
Als einfacher `Job` ist das ausgeschlossen: `JobList::add()` schreibt die Spalte `time_sensitive` nicht (Default `TIME_SENSITIVE`), und nur `JobList::setLastRun()` senkt sie – ausschließlich für `TimedJob && !isTimeSensitive()`, ohne Rückweg. Alte, einmal als insensitive gestempelte Zeilen bleiben es (auch nach App-Update). Einmalig reparieren:
|
||||
|
||||
```sql
|
||||
UPDATE oc_jobs SET time_sensitive = 0
|
||||
WHERE class = 'OCA\\WorkflowDeckAutomation\\BackgroundJob\\RunWorkflowsJob';
|
||||
```
|
||||
|
||||
or, without DB access, `occ background-job:delete <id>` followed by `occ app:disable workflow_deck_automation && occ app:enable workflow_deck_automation` to get a freshly inserted row. Fresh installs are unaffected.
|
||||
oder `occ background-job:delete <id>`, dann `occ app:disable workflow_deck_automation && occ app:enable workflow_deck_automation`.
|
||||
|
||||
To debug this job on a server: `occ background-job:list --class '<class>'` for its id and last run, then `occ background-job:execute <id> --force-execute` to run it right now regardless of the interval. `occ background-job:worker` takes **job classes**, not an app id.
|
||||
Debuggen: `occ background-job:list --class '<Klasse>'` (ID, letzter Lauf), dann `occ background-job:execute <id> --force-execute`. `background-job:worker` erwartet **Job-Klassen**, keine App-ID.
|
||||
|
||||
## Registration is declarative, not Bootstrap-based
|
||||
## Registrierung deklarativ statt über Bootstrap
|
||||
|
||||
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.
|
||||
Hintergrundjob und persönliche Einstellungen stehen in `appinfo/info.xml` (`<background-jobs>`, `<settings><personal>/<personal-section>`). `IRegistrationContext` hat dafür keine Methoden. `lib/AppInfo/Application.php` ist absichtlich fast leer und registriert nur den `Notifier` (dafür gibt es keinen `info.xml`-Eintrag).
|
||||
|
||||
## Commands
|
||||
## Architektur
|
||||
|
||||
**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).
|
||||
- **UI:** `src/PersonalSettings.vue` (Vue 3), gemountet aus `templates/settings/personal.php` (Sektion: `lib/Settings/PersonalSection.php` + `Personal.php`) → `src/api.js` → `lib/Controller/WorkflowController.php` (`OCSController`, Routen in `appinfo/routes.php`, `/ocs/v2.php/apps/workflow_deck_automation/api/v1/...`).
|
||||
- **Speicher:** `lib/Db/Workflow.php` / `WorkflowMapper.php`, Tabelle `wfda_workflows` (Migrationen in `lib/Migration/`).
|
||||
- **Automation:** `RunWorkflowsJob` → `WorkflowRunner` → `DeckIntegrationService` (alle Deck-Aufrufe), `NotificationMailer` (`IMailer`, an die Account-Adressen der Empfänger, nie an eine eingegebene Adresse) und `NotificationPusher` (`IManager`, Texte aus `lib/Notification/Notifier.php`).
|
||||
- Controller (Dropdowns) und Runner (Karten lesen/verschieben) nutzen denselben `DeckIntegrationService`.
|
||||
- **Doku:** `README.md` richtet sich an Anwender und Admins und enthält keine Klassennamen. `DEVELOPMENT.md` ist für Entwickler (Aufbau, Datenmodell, Tests, CI, Release, Fehlersuche). Neue Nutzer-Funktionen in der README beschreiben, technische Details in `DEVELOPMENT.md`. Beide Dateien sind per `Makefile` vom Paket ausgeschlossen.
|
||||
|
||||
Commands as they'd run in CI/on a machine that has the tools:
|
||||
## Befehle und Verifikation
|
||||
|
||||
**Lokal gibt es kein PHP und kein npm** (Windows, siehe `CLAUDE.local.md`) – `composer`/`php`/`npm` nicht lokal ausführen. Verifiziert wird über Push/PR und den Gitea-Actions-Lauf (Runner `gitea-runner-server03`).
|
||||
|
||||
```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
|
||||
composer run lint # php -l über lib/ und tests/
|
||||
composer run cs:check # nextcloud/coding-standard; cs:fix korrigiert
|
||||
composer run test:unit # PHPUnit, nur reine Logik
|
||||
vendor/bin/phpunit --filter <testName> tests/Unit/Service/WorkflowRunnerTest.php
|
||||
|
||||
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
|
||||
npm install # nicht `npm ci`: Lockfiles sind absichtlich nicht committet
|
||||
npm run build # bzw. npm run watch
|
||||
```
|
||||
|
||||
To run a single PHPUnit test: `vendor/bin/phpunit --filter testUserFilterIsOrAgainstAssignedUsers tests/Unit/Service/WorkflowRunnerFilterTest.php`.
|
||||
`composer.lock` und `package-lock.json` stehen in `.gitignore` → CI muss `composer install`/`npm install` verwenden (`npm ci` hat den Build schon einmal gebrochen). Die Deck-Integration lässt sich nur auf einer echten Nextcloud+Deck-Instanz testen.
|
||||
|
||||
There is no meaningful way to test the Deck integration itself outside a real Nextcloud+Deck instance (`php occ background-job:worker 'OCA\WorkflowDeckAutomation\BackgroundJob\RunWorkflowsJob'` — the argument is a job *class*, not an app id) — the unit tests intentionally stop at the pure filter logic.
|
||||
## Release und CI
|
||||
|
||||
### Release process
|
||||
**Versionsschema:** `<Nextcloud-Major>.<minor>.<patch>`. Die erste Zahl entspricht `min-/max-version` in `info.xml` und ändert sich nur mit der unterstützten NC-Major (dann `minor.patch` = `0.0`). `0.x`-Versionen stammen aus der Zeit davor. Die 34er-Linie existiert nur noch als Tags (`v34.*`). Kein Branch.
|
||||
|
||||
1. Bump `<version>` in `appinfo/info.xml` and `version` in `package.json` (keep them in sync).
|
||||
2. Commit, push to `main`, confirm the run of `.gitea/workflows/build-main.yml` is green.
|
||||
3. `git tag vX.Y.Z && git push origin vX.Y.Z` — this triggers the tag branch of the same workflow, which re-runs the checks, 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.
|
||||
1. `<version>` in `appinfo/info.xml` und `version` in `package.json` synchron erhöhen.
|
||||
2. Committen, nach `main` pushen, `build-main.yml` muss grün sein.
|
||||
3. `git tag vX.Y.Z && git push origin vX.Y.Z` → Checks, Frontend-Build, `make appstore`, Release mit `workflow_deck_automation.tar.gz`.
|
||||
|
||||
**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.
|
||||
Ist ein Tag-Build fehlgeschlagen und **nie ein Artefakt entstanden**, den Tag auf den Fix verschieben statt die Version zu erhöhen (`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`). Der Job löscht ein vorhandenes Release vorher. Nach einem veröffentlichten Artefakt den Tag nicht mehr verschieben.
|
||||
|
||||
**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.
|
||||
**`.gitea/workflows/build-main.yml` ist der einzige Build-/Release-Workflow** (Checks `php-lint` mit PHP 8.4 und 8.5, `xml-lint`, `unit-tests` + Job `package`), damit `needs:` das Release an grüne Checks koppelt. Nicht wieder aufteilen.
|
||||
|
||||
### One workflow for everything
|
||||
**`.gitea/workflows/appstore.yml` ist nur manuell (`workflow_dispatch`) und soll so bleiben.** Er baut nicht selbst, sondern signiert den Tarball eines vorhandenen `v*`-Release und meldet dessen Gitea-Download-URL beim App Store an (Secrets `APPSTORE_PRIVATE_KEY`, `APPSTORE_TOKEN`). Signiert wird genau die Datei hinter der URL. Nach dem Store-Upload darf der Tag also nicht mehr verschoben werden, sonst passt die Signatur nicht mehr. `latest-main` wird abgelehnt. Kein `appinfo/signature.json` (Code-Signing): Das braucht der Store nicht, und Nextcloud prüft es bei Nicht-Core-Apps nur, wenn die Datei existiert.
|
||||
|
||||
`.gitea/workflows/build-main.yml` is the *only* workflow: it carries the checks (`php-lint` across 8.2/8.3/8.4, `xml-lint`, `unit-tests`) and the `package` job that builds and publishes. They used to be four separate workflow files, which meant they all triggered independently on the same push and a `latest-main` release was published even when PHPUnit was red. `needs:` only works between jobs of the same workflow (and Gitea's `workflow_run` can depend on one workflow at a time), so gating the release on the checks required merging them into this file. Don't split them back out.
|
||||
- `package` baut *und* veröffentlicht in einem Job und braucht nur Node (`make appstore` ist reines `mkdir`/`tar`/`rm`).
|
||||
- Bei `pull_request` wird `Publish release` übersprungen (`if: gitea.event_name == 'push'`) und der Job ist nur ein Build-Check.
|
||||
- Push auf `main` → Pre-Release `latest-main`, Release *und* Tag werden jedes Mal neu erzeugt (Download-Link zeigt immer den neuesten Build). `v*`-Tag → normales Release. Dabei wird **nie der Tag** gelöscht (Flag `RECREATE_TAG`).
|
||||
- **Kein `actions/upload-artifact`:** Gitea gibt sich als GHES aus, `@actions/artifact` ≥ 2 verweigert dann (`GHESNotSupportedError`). Veröffentlicht wird per `curl` gegen die Gitea-Release-API. `checkout`/`setup-node` sind nicht betroffen.
|
||||
- **Kein PAT nötig:** Auth über das automatische `secrets.GITEA_TOKEN` mit `permissions: contents: write`. Bei 403: prüfen, ob die Token-Berechtigung des Repos/der Org auf *Restricted* steht (deckelt `permissions:`). Das ist eine Einstellung, kein Workflow-Bug.
|
||||
|
||||
Consequences of that shape, all intentional:
|
||||
## Frontend-Build-Fallen (alle erst in CI aufgefallen)
|
||||
|
||||
- **`package` builds *and* publishes in one job.** Handing the tarball to a separate publish job would need `actions/upload-artifact` (unusable here, see above) or `actions/cache` — a second Gitea backend API in the critical path for no gain.
|
||||
- **The workflow also runs on `pull_request`**, where the `Publish release` step is skipped via `if: gitea.event_name == 'push'` and the job degrades into a frontend-build check. That matters: `npm run build` used to run only on `main`, so no PR ever exercised it.
|
||||
- **Tags now get linted and tested too.** Previously the check workflows triggered only on `main` pushes and PRs, so a `v*` tag published completely unverified.
|
||||
- **`vite` muss zum Peer von `@nextcloud/vite-config` passen** (aktuell `vite@^7.3.6`), sonst `ERESOLVE`. Beim Update von `vite-config` dessen `peerDependencies.vite` prüfen.
|
||||
- **`"type": "module"` in `package.json`**, sonst scheitert `vite.config.js` am ESM-only-Paket.
|
||||
- **`tsconfig.json` und `typescript` als devDependency sind Pflicht**, obwohl es kein TypeScript gibt: `@nextcloud/vite-config` importiert über sein Barrel `vite-plugin-dts`, das `typescript` als Peer braucht. Ohne das Paket crasht es beim Laden (`Cannot read properties of undefined (reading 'useCaseSensitiveFileNames')`). `typescript` nahe an der Version von `vite-config` halten (aktuell `^5.9.3`).
|
||||
- **`createAppConfig` stellt die App-ID vor jeden Entry-Namen:** `personal-settings` → `js/workflow_deck_automation-personal-settings.mjs`. `Util::addScript()`/`addStyle()` in `lib/Settings/Personal.php` brauchen diesen vollen Namen (`Application::APP_ID . '-personal-settings'`). Falsch → **leere Einstellungsseite**, nur `Could not find resource …` im Log. Umbenennungen in `vite.config.js` auch dort nachziehen.
|
||||
- Die eigentliche CSS steckt in einem gehashten `*.chunk.css`. Die Entry-CSS ist ein ~100-Byte-Stub (`createEmptyCSSEntryPoints`). Das ist korrekt, also Code-Splitting nicht abschalten.
|
||||
|
||||
It runs on pushes to `main` **and** on `v*` tags, and the publish step branches on `gitea.ref`:
|
||||
## `@nextcloud/vue` 9 (Vue 3) – v8-Idiome scheitern still
|
||||
|
||||
- **`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.
|
||||
- **Kein `.sync`:** `NcTextField`/`NcCheckboxRadioSwitch` → `v-model`. `:value.sync` lässt den Wert auf dem Startwert stehen.
|
||||
- **`NcSelect`: `label` ist der Anzeige-Schlüssel der Optionen** (vue-select), die Beschriftung ist `input-label`. `label="Board"` rendert überall `undefined`.
|
||||
- **`NcButton`: `variant` = Stil, `type` = nativer Button-Typ**, `native-type` gibt es nicht mehr. Ein übrig gebliebenes `type="primary"` wird zu `submit`, und Abbrechen schickt das Formular ab.
|
||||
- `NcSelect`s `reduce` erreicht vue-select nur per Attribut-Fallthrough (funktioniert, ist aber undokumentiert).
|
||||
- `NcNoteCard` nutzt weiterhin `type` (`success|info|warning|error`).
|
||||
|
||||
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.
|
||||
## Deck-Interna, die man kennen muss
|
||||
|
||||
Note that `make appstore` is pure `mkdir`/`tar`/`rm` — no `composer`, no `php`. The `package` job therefore needs Node only; a `setup-php` step *there* would be dead weight (the deleted release workflow had one). PHP is set up in the check jobs, which is a different job and a different container.
|
||||
|
||||
**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()` defaults to `$includeArchived = true`, and that flag also gates the `deleted_at = 0` condition** — with the default you get archived *and* trashed boards back. We call `getUserBoards(null, false)` (with a fallback to the no-arg form if Deck ever changes the signature) and still dedupe by id afterwards, because it merges own/group/circle boards and can return the same board twice. Stacks need no such handling: `StackMapper::findAll()` filters `deleted_at = 0` unconditionally and stacks have no archived state.
|
||||
|
||||
## 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.
|
||||
- **Relationale Entities:** Auf angereicherten Entities liefern `$acl->getParticipant()`, `$board->getOwner()` oder Assignment-Teilnehmer ein `RelationalObject` statt einer uid (uid = `getPrimaryKey()`). Neue Teilnehmer-/Owner-Felder immer über `DeckIntegrationService::unwrapUid()` laufen lassen.
|
||||
- Die ACL eines Boards enthält **nicht** den Owner (privates Board = leere ACL). ACL-Einträge können Gruppen (`type === 1`) oder Circles sein.
|
||||
- `StackMapper::findAll()` filtert `deleted_at = 0` selbst. Stacks haben keinen Archivstatus.
|
||||
- Der Deck-Quellcode liegt lokal unter `C:\Users\pani\Documents\git-privat\nextcloud-deck` und dient zum Nachprüfen von Interna.
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
# Entwicklung
|
||||
|
||||
Informationen für Entwickler und Admins, die tiefer einsteigen. Die Nutzer-Doku steht in der [README](README.md). Ausführliche Hintergründe zu den Stolperfallen, also warum etwas genau so gebaut ist, stehen in [CLAUDE.md](CLAUDE.md).
|
||||
|
||||
## Aufbau
|
||||
|
||||
- **Keine HTTP/OCS-Aufrufe gegen Deck.** Lesen (Boards, Stapel, Karten, Board-Mitglieder) und das Verschieben laufen ausschließlich über Decks interne PHP-Klassen im selben Prozess. Sämtlicher Deck-Zugriff ist in [`lib/Service/DeckIntegrationService.php`](lib/Service/DeckIntegrationService.php) gebündelt.
|
||||
- `OCA\Deck\*` ist keine stabile öffentliche API. Jeder Zugriff ist deshalb abgesichert: `class_exists()`-Checks, Prüfung ob Deck aktiviert ist, try/catch mit Logging.
|
||||
- Nach größeren Deck-Updates lohnt sich ein Blick ins Nextcloud-Log.
|
||||
- **Hintergrundjob:** [`RunWorkflowsJob`](lib/BackgroundJob/RunWorkflowsJob.php) ist ein einfacher `Job` ohne Intervall. Er läuft bei jedem Cron-Durchlauf und ruft [`WorkflowRunner`](lib/Service/WorkflowRunner.php) auf.
|
||||
- **Rechte-Kontext:** Der Runner arbeitet die Workflows nach Eigentümer gruppiert ab und setzt Deck jeweils auf diesen Nutzer, per `DeckIntegrationService::beginUserContext()`. Decks Berechtigungsprüfungen lesen nämlich nicht die Session, sondern eine pro Prozess festgelegte Nutzer-ID.
|
||||
- **E-Mail:** [`NotificationMailer`](lib/Service/NotificationMailer.php) verschickt über Nextclouds `IMailer`. Die Empfänger ermittelt `DeckIntegrationService::findNotificationRecipients()`, die Entscheidungsregel steckt in [`NotificationPolicy`](lib/Service/NotificationPolicy.php).
|
||||
- **Nextcloud-Benachrichtigungen:** [`NotificationPusher`](lib/Service/NotificationPusher.php) legt sie über `OCP\Notification\IManager` an, eine pro Karte, an dieselben Empfänger wie die Mail. Er speichert nur Rohwerte. Den deutschen Text baut [`Notifier`](lib/Notification/Notifier.php), registriert in `Application::register()`. Ist die App `notifications` für einen Empfänger nicht aktiv, wird nichts angelegt. Push aufs Handy übernimmt die Notifications-App selbst.
|
||||
- **Oberfläche:** [`src/PersonalSettings.vue`](src/PersonalSettings.vue) (Vue 3, `@nextcloud/vue` 9) spricht über [`src/api.js`](src/api.js) mit dem OCS-Controller [`WorkflowController`](lib/Controller/WorkflowController.php).
|
||||
|
||||
## Datenmodell
|
||||
|
||||
Die Tabelle `wfda_workflows` enthält eine Zeile pro Workflow:
|
||||
|
||||
| Spalte | Bedeutung |
|
||||
| --- | --- |
|
||||
| `board_id`, `source_stack_id`, `target_stack_id` | Deck-IDs |
|
||||
| `user_id` | Board-Eigentümer, in dessen Namen der Job läuft. Wird bei jedem Lauf aus Deck aktualisiert. |
|
||||
| `notify_mode` | `off`, `owner` oder `board` (wer) |
|
||||
| `notify_channel` | `mail`, `notification` oder `both` (wie) |
|
||||
| `enabled`, `last_run` | aktiv? / letzter Lauf |
|
||||
|
||||
Migrationen in [`lib/Migration/`](lib/Migration/):
|
||||
- `Version1000…` legt die Tabelle an.
|
||||
- `Version1001…` entfernt die früheren Filter-Spalten.
|
||||
- `Version1002…` und `Version1003…` ersetzen `notify_email` durch `notify_mode`. Ein gesetztes Häkchen wird dabei zu `owner`.
|
||||
- `Version1004…` fügt `notify_channel` hinzu. Bestehende Workflows bekommen `mail`.
|
||||
|
||||
Migrationen laufen nur, wenn die App-Version steigt.
|
||||
|
||||
## Lokales Setup
|
||||
|
||||
Voraussetzungen: PHP 8.4+, Composer, Node.js 24 mit npm.
|
||||
|
||||
```bash
|
||||
composer install
|
||||
npm install # nicht `npm ci`: Lockfiles werden bewusst nicht committet
|
||||
npm run build # Produktions-Build
|
||||
npm run watch # Entwicklung mit automatischem Rebuild
|
||||
```
|
||||
|
||||
## Tests und Linting
|
||||
|
||||
```bash
|
||||
composer run lint # php -l über lib/ und tests/
|
||||
composer run cs:check # nextcloud/coding-standard (cs:fix korrigiert)
|
||||
composer run test:unit # PHPUnit
|
||||
```
|
||||
|
||||
Die Unit-Tests decken nur reine Logik ab, ohne Deck-Installation:
|
||||
- Fälligkeitsprüfung (`WorkflowRunner::isOverdue()`)
|
||||
- Empfängerregel (`NotificationPolicy::shouldNotify()`)
|
||||
- Benachrichtigungsweg (`NotificationPolicy::sendsMail()`/`sendsNotification()`)
|
||||
|
||||
Die Deck-Anbindung lässt sich nur auf einer echten Nextcloud-Instanz mit Deck testen:
|
||||
|
||||
```bash
|
||||
# Job-ID und letzten Lauf anzeigen
|
||||
php occ background-job:list --class 'OCA\WorkflowDeckAutomation\BackgroundJob\RunWorkflowsJob'
|
||||
|
||||
# Sofort ausführen, ohne auf Cron zu warten
|
||||
php occ background-job:execute <id> --force-execute
|
||||
```
|
||||
|
||||
## Build, CI und Release
|
||||
|
||||
`make appstore` erzeugt `build/artifacts/appstore/workflow_deck_automation.tar.gz`.
|
||||
|
||||
Gebaut und veröffentlicht wird in [`.gitea/workflows/build-main.yml`](.gitea/workflows/build-main.yml):
|
||||
|
||||
| Job | Zweck |
|
||||
| --- | --- |
|
||||
| `php-lint` | `php -l` mit PHP 8.4 und 8.5 |
|
||||
| `xml-lint` | prüft `appinfo/info.xml` gegen das Appstore-Schema |
|
||||
| `unit-tests` | PHPUnit |
|
||||
| `package` | Frontend-Build und `make appstore`. Läuft nur, wenn die Checks grün sind. |
|
||||
|
||||
Das Paket wird als Release veröffentlicht:
|
||||
- Bei jedem Push auf `main` als Pre-Release **`latest-main`**, das jedes Mal neu erzeugt wird.
|
||||
- Bei einem `v*`-Tag als reguläres Release.
|
||||
- Bei Pull Requests wird nur gebaut, nicht veröffentlicht.
|
||||
|
||||
**Versionen** folgen dem Schema `<Nextcloud-Major>.<minor>.<patch>`. Die unterstützte Nextcloud-Version steht in `appinfo/info.xml`.
|
||||
|
||||
Ablauf für ein Release:
|
||||
1. Version in `appinfo/info.xml` und `package.json` gleich anheben.
|
||||
2. Auf `main` pushen und warten, bis die Pipeline grün ist.
|
||||
3. `git tag -a vX.Y.Z -m "…" && git push origin vX.Y.Z`.
|
||||
4. Optional in den App Store: in Gitea unter *Actions* den Workflow „Publish to App Store“ von Hand starten und den Tag angeben.
|
||||
|
||||
### App Store
|
||||
|
||||
[`.gitea/workflows/appstore.yml`](.gitea/workflows/appstore.yml) läuft nur von Hand (`workflow_dispatch`). Er baut nichts selbst. Er lädt den Tarball eines vorhandenen `vX.Y.Z`-Release ohne Anmeldung herunter und prüft, ob die Version in `info.xml` zum Tag passt. Dann signiert er das Archiv (SHA-512) und meldet die Download-URL samt Signatur an die App-Store-API. Der Store lädt den Tarball danach selbst von Gitea. Die Releases müssen deshalb öffentlich erreichbar sein, und ein Tag darf nach dem Store-Upload nicht mehr verschoben werden.
|
||||
|
||||
Benötigte Repository-Secrets:
|
||||
|
||||
| Secret | Inhalt |
|
||||
| --- | --- |
|
||||
| `APPSTORE_PRIVATE_KEY` | privater Schlüssel (PEM), zu dem Nextcloud das App-Zertifikat ausgestellt hat |
|
||||
| `APPSTORE_TOKEN` | API-Token von <https://apps.nextcloud.com/account/token> |
|
||||
|
||||
Einmalig vorher: Zertifikat über [nextcloud/app-certificate-requests](https://github.com/nextcloud/app-certificate-requests) beantragen und die App auf apps.nextcloud.com registrieren. Die App wird nicht per Code-Signing (`appinfo/signature.json`) signiert. Nextcloud prüft Apps, die nicht zum Core gehören, nur dann, wenn diese Datei vorhanden ist.
|
||||
|
||||
Einzelheiten, etwa zum Verschieben eines Tags nach einem fehlgeschlagenen Build, stehen in [CLAUDE.md](CLAUDE.md).
|
||||
|
||||
## Fehlersuche: Der Job läuft nur nachts oder gar nicht
|
||||
|
||||
Nextcloud überspringt „nicht zeitkritische“ Jobs außerhalb des Wartungsfensters (`maintenance_window_start` in der `config.php`). Bis einschließlich v0.1.0 war dieser Job als nicht zeitkritisch markiert. Diese Markierung bleibt in der Spalte `oc_jobs.time_sensitive` stehen, auch nach App-Updates.
|
||||
|
||||
Symptom: Der Job läuft nur im 4-Stunden-Wartungsfenster, oder `occ background-job:list` zeigt als letzten Lauf `1970-01-01`.
|
||||
|
||||
Einmalig korrigieren:
|
||||
|
||||
```sql
|
||||
UPDATE oc_jobs SET time_sensitive = 0
|
||||
WHERE class = 'OCA\\WorkflowDeckAutomation\\BackgroundJob\\RunWorkflowsJob';
|
||||
```
|
||||
|
||||
Oder ohne Datenbankzugriff den Job löschen und durch Aus- und Einschalten der App neu registrieren:
|
||||
|
||||
```bash
|
||||
php occ background-job:delete <id>
|
||||
php occ app:disable workflow_deck_automation
|
||||
php occ app:enable workflow_deck_automation
|
||||
```
|
||||
|
||||
Die frühere `config.php`-Option `workflow_deck_automation.interval` (bis v0.1.0) wird ignoriert und kann entfernt werden.
|
||||
@@ -0,0 +1,661 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
@@ -24,6 +24,7 @@ appstore: clean
|
||||
--exclude=./.php-cs-fixer.cache \
|
||||
--exclude=./phpunit.xml \
|
||||
--exclude=./README.md \
|
||||
--exclude=./DEVELOPMENT.md \
|
||||
--exclude=./Makefile \
|
||||
--exclude=./CLAUDE.md \
|
||||
--exclude=./CLAUDE.local.md \
|
||||
|
||||
@@ -1,145 +1,101 @@
|
||||
# Deck Workflow-Automatisierung
|
||||
|
||||
Nextcloud-App für **Nextcloud Hub 26 Spring (Server 34.x)**, die überfällige Karten der [Deck](https://github.com/nextcloud/deck)-App automatisch von einem Stapel in einen anderen verschiebt und optional eine E-Mail-Benachrichtigung verschickt.
|
||||
Nextcloud-App, die überfällige Karten der [Deck](https://apps.nextcloud.com/apps/deck)-App automatisch von einem Stapel in einen anderen verschiebt und auf Wunsch per E-Mail oder Nextcloud-Benachrichtigung (Push) darüber informiert.
|
||||
|
||||
Ersetzt das früher genutzte eigenständige PHP-Cron-Skript durch eine vollwertige App mit Oberfläche in den **persönlichen Einstellungen** — jeder Nutzer kann dort seine eigenen Regeln ("Workflows") anlegen, ohne Admin-Rechte oder Server-Zugriff zu benötigen.
|
||||
Die Regeln („Workflows“) richtet der **Eigentümer eines Boards** selbst in seinen **persönlichen Einstellungen** ein. Admin-Rechte braucht er dafür nicht. Für geteilte Boards anderer Nutzer lassen sich keine Workflows anlegen.
|
||||
|
||||
## Funktionsumfang
|
||||
> Entwickler-Informationen (Aufbau, Tests, Build, Release) stehen in [DEVELOPMENT.md](DEVELOPMENT.md).
|
||||
|
||||
Pro Workflow lässt sich konfigurieren:
|
||||
## Was die App macht
|
||||
|
||||
- Quell-Board und Quell-Stapel
|
||||
- Ziel-Stapel, in den überfällige Karten verschoben werden
|
||||
- optionaler Filter auf zugewiesene Benutzer (Karte muss **mindestens einem** der gewählten Benutzer zugewiesen sein)
|
||||
- optionaler Filter auf Labels/Tags (Karte muss **mindestens eines** der gewählten Labels haben)
|
||||
- Checkbox: E-Mail-Benachrichtigung an den Workflow-Besitzer, sobald eine Karte verschoben wurde
|
||||
Ein Workflow besteht aus einem Board, einem **Quell-Stapel** und einem **Ziel-Stapel**. Bei jedem Durchlauf verschiebt die App **alle überfälligen Karten** des Quell-Stapels oben in den Ziel-Stapel.
|
||||
|
||||
Ein Nutzer kann beliebig viele Workflows anlegen, bearbeiten, deaktivieren oder löschen.
|
||||
Überfällig ist eine Karte, deren Fälligkeitsdatum samt Uhrzeit überschritten ist. Karten ohne Fälligkeitsdatum sowie erledigte, archivierte oder gelöschte Karten bleiben liegen.
|
||||
|
||||
## Architektur
|
||||
Beispiel: Ein Workflow „Waiting → ToDo“ holt jede Karte aus „Waiting / Blocked“ nach „Work in Progress / ToDo“, sobald ihr Fälligkeitsdatum erreicht ist.
|
||||
|
||||
- **Keine HTTP/OCS-Aufrufe gegen Deck.** Alles Lesen (Boards, Stapel, Karten, Labels, zugewiesene Benutzer) und das Verschieben von Karten läuft ausschließlich über Decks eigene interne PHP-Klassen (`OCA\Deck\Service\CardService`, `StackService`, `BoardService`, `OCA\Deck\Db\CardMapper`, …), aufgelöst per Dependency Injection direkt im selben PHP-Prozess. Sämtlicher Deck-Zugriff ist in [`lib/Service/DeckIntegrationService.php`](lib/Service/DeckIntegrationService.php) gebündelt.
|
||||
- **Hintergrundjob statt Seitenaufruf.** [`lib/BackgroundJob/RunWorkflowsJob.php`](lib/BackgroundJob/RunWorkflowsJob.php) ist ein `TimedJob`, der standardmäßig alle 5 Minuten läuft (abhängig vom Nextcloud-Cron-Intervall) und [`lib/Service/WorkflowRunner.php`](lib/Service/WorkflowRunner.php) aufruft. Das Intervall ist über die `config.php` einstellbar, siehe unten.
|
||||
- **Rechte-Kontext je Nutzer.** Da Decks Berechtigungsprüfungen die aktuell eingeloggte Session lesen, ein Hintergrundjob aber standardmäßig keinen eingeloggten Nutzer hat und Regeln mehrerer Nutzer in einem einzigen Lauf auswerten muss, "verkörpert" der `WorkflowRunner` für die Dauer der jeweiligen Workflows kurzzeitig den entsprechenden Besitzer (`IUserSession::setUser()`), bevor die Deck-Klassen aufgerufen werden.
|
||||
- **E-Mail** läuft über Nextclouds eigenen `IMailer` (nutzt also den in der Nextcloud-Administration hinterlegten Mailserver) und geht an die im Profil des Workflow-Besitzers hinterlegte Adresse.
|
||||
## Einrichtung (für Administratoren)
|
||||
|
||||
### Wichtiger Hinweis zu Decks internen Klassen
|
||||
1. App nach `apps/workflow_deck_automation` auf den Server kopieren, zum Beispiel aus dem Release-Archiv `workflow_deck_automation.tar.gz`, und unter *Apps* aktivieren.
|
||||
2. Die [Deck-App](https://apps.nextcloud.com/apps/deck) muss installiert und für die Nutzer aktiviert sein.
|
||||
3. Die [Hintergrundjobs](https://docs.nextcloud.com/server/latest/admin_manual/configuration_server/background_jobs_configuration.html) müssen auf *Cron* stehen.
|
||||
4. Für E-Mails muss in der Nextcloud-Administration ein Mailserver eingerichtet sein.
|
||||
5. Für Nextcloud-Benachrichtigungen muss die App **Benachrichtigungen** (`notifications`) aktiviert sein, was sie standardmäßig ist. Fehlt sie, verschickt die App einfach keine Benachrichtigungen. E-Mails kommen trotzdem.
|
||||
|
||||
`OCA\Deck\*` ist keine dokumentierte, stabile öffentliche API von Deck (keine `@since`-Markierungen, keine offizielle Zusicherung von Abwärtskompatibilität). Diese App verwendet sie trotzdem bewusst direkt, wie es die Vorgabe verlangt — jeder Zugriff läuft defensiv abgesichert über `DeckIntegrationService` (Prüfung, ob Deck aktiviert ist, `class_exists()`-Checks, try/catch mit Logging statt Absturz). Nach größeren Deck-Updates lohnt sich ein Blick ins Nextcloud-Log, falls Workflows plötzlich nicht mehr greifen.
|
||||
Die Workflows laufen bei **jedem** Cron-Durchlauf von Nextcloud, üblicherweise alle 5 Minuten. Ein eigenes Intervall gibt es nicht.
|
||||
|
||||
## Installation
|
||||
## Workflows anlegen
|
||||
|
||||
1. App in `apps/workflow_deck_automation` des Nextcloud-Servers ablegen (oder über den Appstore-Build aus der CI, siehe unten).
|
||||
2. In der Nextcloud-Administration unter *Apps* aktivieren.
|
||||
3. Die [Deck-App](https://apps.nextcloud.com/apps/deck) muss installiert und für die jeweiligen Nutzer aktiviert sein.
|
||||
4. Sicherstellen, dass der [Hintergrundjob-Modus](https://docs.nextcloud.com/server/latest/admin_manual/configuration_server/background_jobs_configuration.html) auf *Cron (empfohlen)* steht, damit `RunWorkflowsJob` regelmäßig läuft.
|
||||
Die Einstellungen liegen unter **Persönliche Einstellungen → Deck Workflow-Automatisierung**. Dort legst du Workflows an und kannst sie bearbeiten, deaktivieren oder löschen.
|
||||
|
||||
## Benutzung
|
||||
- **Board:** Zur Auswahl stehen nur Boards, deren **Eigentümer** du bist. Geteilte, archivierte und gelöschte Boards erscheinen nicht.
|
||||
- **Quell- und Ziel-Stapel:** Das müssen zwei verschiedene Stapel desselben Boards sein.
|
||||
- **Benachrichtigung bei verschobenen Karten** und **Benachrichtigen per:** siehe unten.
|
||||
- **Workflow aktiv:** Deaktivierte Workflows bleiben gespeichert, verschieben aber nichts.
|
||||
|
||||
Jeder Nutzer findet die Einstellungen unter **Persönliche Einstellungen → Deck Workflow-Automatisierung**. Dort können neue Workflows angelegt, bestehende bearbeitet oder gelöscht werden. Die Dropdowns für Board/Stapel/Label/Benutzer werden live aus Deck geladen. Zur Auswahl stehen nur aktive Boards — archivierte und im Papierkorb liegende Boards sowie gelöschte Stapel blendet Deck bzw. die App aus.
|
||||
Ein Workflow gehört immer dem Eigentümer des Boards. Überträgst du ein Board in Deck an jemand anderen, wandern seine Workflows mit: Der neue Eigentümer sieht und verwaltet sie ab sofort, und die Karten werden in seinem Namen verschoben.
|
||||
|
||||
### Gelöschte Boards und Stapel
|
||||
## Benachrichtigungen
|
||||
|
||||
Ein Workflow verweist per ID auf ein Board und zwei Stapel. Wird eines davon in Deck gelöscht, passiert Folgendes:
|
||||
Pro Workflow stellst du ein, **wer** über verschobene Karten informiert wird und **wie**.
|
||||
|
||||
- **Beim Speichern** werden Board und Stapel geprüft; ein Workflow auf nicht mehr existierende Ziele lässt sich gar nicht erst anlegen.
|
||||
- **In der Übersicht** wird ein betroffener Workflow rot markiert („Board nicht mehr vorhanden", „Quell-Stapel nicht mehr vorhanden" …).
|
||||
- **Im Hintergrundjob** wird der Workflow automatisch deaktiviert und der Besitzer einmalig per E-Mail informiert — unabhängig davon, ob für den Workflow „E-Mail senden" aktiviert ist, denn hier geht es nicht um verschobene Karten, sondern darum, dass die Automatisierung nicht mehr läuft. Danach bleibt es still: Ein deaktivierter Workflow wird nicht erneut ausgewertet.
|
||||
### Wer?
|
||||
|
||||
Ist Deck vorübergehend nicht erreichbar, wird **nicht** deaktiviert — der Job überspringt den Workflow und versucht es beim nächsten Lauf erneut. Archivierte Boards lösen die Deaktivierung ebenfalls nicht aus, sie sind nur in den Auswahlfeldern ausgeblendet.
|
||||
|
||||
## Konfiguration
|
||||
|
||||
Das Prüfintervall des Hintergrundjobs lässt sich in der `config.php` setzen (Wert in **Sekunden**):
|
||||
|
||||
```php
|
||||
'workflow_deck_automation.interval' => 300,
|
||||
```
|
||||
|
||||
- Standard ohne Eintrag: `300` (5 Minuten).
|
||||
- Minimum: `60` — kleinere Werte werden auf 60 Sekunden angehoben.
|
||||
- Die Änderung greift beim nächsten Cron-Durchlauf; ein `occ`-Befehl oder eine Neuinstallation der App ist nicht nötig.
|
||||
|
||||
Zu beachten: Das ist eine *Untergrenze* für den Abstand zwischen zwei Läufen, keine Garantie. Nextclouds Cron selbst läuft üblicherweise nur alle 5 Minuten — ein Intervall von 60 Sekunden führt also nur dann zu minütlichen Läufen, wenn der System-Cron entsprechend häufig ausgeführt wird.
|
||||
|
||||
### Der Job läuft nur nachts bzw. gar nicht
|
||||
|
||||
Der Job ist als `TIME_SENSITIVE` deklariert und läuft damit rund um die Uhr. Nextcloud merkt sich die Zeitsensitivität aber zusätzlich in der Spalte `time_sensitive` der Tabelle `oc_jobs`, und `JobList::setLastRun()` setzt sie nur in eine Richtung — von „zeitkritisch" auf „unkritisch", nie zurück. Wer eine ältere Version dieser App installiert hatte (bis einschließlich v0.1.0 war der Job `TIME_INSENSITIVE`), hat diesen Stempel noch in der Datenbank. Er wird weder durch ein App-Update noch durch `app:enable` zurückgesetzt.
|
||||
|
||||
Symptom: Ist in der `config.php` ein `maintenance_window_start` gesetzt, läuft der Job nur in diesem 4-Stunden-Fenster und den Rest des Tages gar nicht.
|
||||
|
||||
Einmalig korrigieren:
|
||||
|
||||
```sql
|
||||
UPDATE oc_jobs SET time_sensitive = 0
|
||||
WHERE class = 'OCA\\WorkflowDeckAutomation\\BackgroundJob\\RunWorkflowsJob';
|
||||
```
|
||||
|
||||
Ohne Datenbankzugriff geht es auch über `occ`, indem der Job gelöscht und durch Aus-/Einschalten der App neu registriert wird:
|
||||
|
||||
```bash
|
||||
php occ background-job:list --class 'OCA\WorkflowDeckAutomation\BackgroundJob\RunWorkflowsJob'
|
||||
php occ background-job:delete <id>
|
||||
php occ app:disable workflow_deck_automation
|
||||
php occ app:enable workflow_deck_automation
|
||||
```
|
||||
|
||||
Neuinstallationen sind nicht betroffen.
|
||||
|
||||
## Entwicklung
|
||||
|
||||
Voraussetzungen: PHP 8.2+, Composer, Node.js 24+, npm 11+.
|
||||
|
||||
```bash
|
||||
composer install
|
||||
npm install
|
||||
npm run build # einmaliger Produktions-Build
|
||||
npm run watch # Entwicklung mit automatischem Rebuild
|
||||
```
|
||||
|
||||
### Tests & Linting
|
||||
|
||||
```bash
|
||||
composer run lint # php -l über lib/ und tests/
|
||||
composer run cs:check # nextcloud/coding-standard (php-cs-fixer)
|
||||
composer run test:unit # PHPUnit — reine Filter-Logik, benötigt keine Deck-Installation
|
||||
```
|
||||
|
||||
Ein echter End-to-End-Test (Karte anlegen, Workflow konfigurieren, Hintergrundjob auslösen, Verschiebung + Mail prüfen) lässt sich nur gegen eine echte Nextcloud-34-Instanz mit installierter Deck-App durchführen, z. B. per:
|
||||
|
||||
```bash
|
||||
# Job-ID und letzten Lauf anzeigen
|
||||
php occ background-job:list --class 'OCA\WorkflowDeckAutomation\BackgroundJob\RunWorkflowsJob'
|
||||
|
||||
# Sofort ausführen, unabhängig vom Intervall
|
||||
php occ background-job:execute <id> --force-execute
|
||||
|
||||
# Oder dauerhaft als Worker laufen lassen (Argument ist die Job-Klasse, keine App-ID)
|
||||
php occ background-job:worker 'OCA\WorkflowDeckAutomation\BackgroundJob\RunWorkflowsJob'
|
||||
```
|
||||
|
||||
### Release-Paket bauen
|
||||
|
||||
```bash
|
||||
make appstore
|
||||
```
|
||||
|
||||
Erzeugt `build/artifacts/appstore/workflow_deck_automation.tar.gz`.
|
||||
|
||||
## CI (Gitea Actions)
|
||||
|
||||
In [`.gitea/workflows/`](.gitea/workflows/):
|
||||
|
||||
| Workflow | Zweck |
|
||||
| Einstellung | Wer wird informiert? |
|
||||
| --- | --- |
|
||||
| `lint-php.yml` | `php -l` über eine PHP-8.2–8.4-Matrix |
|
||||
| `lint-info-xml.yml` | validiert `appinfo/info.xml` gegen das Appstore-XML-Schema |
|
||||
| `phpunit.yml` | führt die PHPUnit-Tests aus (SQLite/rein logisch, kein DB-Service nötig) |
|
||||
| `build-main.yml` | baut Frontend + Appstore-Archiv und veröffentlicht es als Release-Asset — bei Push auf `main` als rollendes Pre-Release `latest-main`, bei Push eines `v*`-Tags als reguläres Release |
|
||||
| **Keine Benachrichtigung** | niemand |
|
||||
| **Nur an mich** | der Eigentümer des Boards, unabhängig von seinen Deck-Einstellungen |
|
||||
| **An Board-Mitglieder laut ihrer Deck-Einstellung** | alle, die das Board sehen dürfen, je nach ihrer eigenen Deck-Einstellung (siehe unten) |
|
||||
|
||||
## Datenmodell
|
||||
Bei der dritten Stufe entscheidet jedes Board-Mitglied selbst. Dazu gehören der Eigentümer, Personen mit Freigabe sowie Mitglieder freigegebener Gruppen und Teams. Die Einstellung liegt in Deck im Menü **…** neben dem Board unter **Fälligkeitserinnerungen**:
|
||||
|
||||
Workflows werden in der Tabelle `wfda_workflows` gespeichert (siehe [`lib/Migration/Version1000Date20260813120000.php`](lib/Migration/Version1000Date20260813120000.php)): eine Zeile pro Workflow, mit `user_id`-Bezug, Board-/Stapel-IDs, JSON-kodierten Filterlisten sowie `enabled`/`notify_email`/`last_run`.
|
||||
- **Alle Karten:** für jede verschobene Karte
|
||||
- **Zugewiesene Karten** (Deck-Standard): nur für Karten, denen man zugewiesen ist, direkt oder über eine Gruppe oder ein Team
|
||||
- **Keine Benachrichtigungen:** nie
|
||||
|
||||
Bei einem Board, das mit niemandem geteilt ist, bekommt der Eigentümer jede Karte gemeldet, außer er hat die Erinnerungen ausgeschaltet. So verhält sich auch Deck selbst.
|
||||
|
||||
### Wie?
|
||||
|
||||
| Einstellung | Was kommt an? |
|
||||
| --- | --- |
|
||||
| **E-Mail** (Standard) | eine Sammel-E-Mail pro Durchlauf |
|
||||
| **Nextcloud-Benachrichtigung (Push)** | eine Benachrichtigung pro Karte in der Glocke oben rechts und als Push-Nachricht in den Nextcloud-Apps auf Handy und Desktop |
|
||||
| **E-Mail und Nextcloud-Benachrichtigung** | beides |
|
||||
|
||||
Die Empfänger sind in allen drei Fällen dieselben.
|
||||
|
||||
**Inhalt der E-Mail:** Jeder Empfänger bekommt pro Durchlauf höchstens **eine** E-Mail, auch wenn mehrere Karten verschoben wurden. Pro Karte steht darin ein Satz wie:
|
||||
|
||||
> „Essensvorräte prüfen“ (Board „Persönlich“) ist überfällig und wurde von „Waiting / Blocked“ nach „Work in Progress / ToDo“ verschoben.
|
||||
|
||||
Der Kartentitel ist ein Link direkt zur Karte in Deck. Die Kartenbeschreibung wird nicht mitgeschickt.
|
||||
|
||||
**Inhalt der Nextcloud-Benachrichtigung:** „*Kartentitel* wurde nach *Ziel-Stapel* verschoben“, darunter Board und Quell-Stapel. Ein Klick öffnet die Karte. Wird dieselbe Karte später noch einmal verschoben, ersetzt die neue Benachrichtigung die alte.
|
||||
|
||||
**Keine E-Mail erhalten?**
|
||||
- Ist in deinem Nextcloud-Profil eine E-Mail-Adresse hinterlegt? Ohne Adresse wird die E-Mail übersprungen.
|
||||
- Welche Stufe und welcher Weg sind im Workflow eingestellt?
|
||||
- Bei „An Board-Mitglieder“: Was steht in Deck unter *Fälligkeitserinnerungen* für dieses Board?
|
||||
|
||||
**Keine Push-Nachricht erhalten?**
|
||||
- Ist die App *Benachrichtigungen* aktiviert? Die Einstellungsseite weist darauf hin, wenn nicht.
|
||||
- Für Push aufs Handy muss dort die Nextcloud-App angemeldet sein und Benachrichtigungen erlauben.
|
||||
|
||||
Deck schickt zusätzlich seine eigene Erinnerung, wenn eine Karte fällig wird. Sie kommt von Deck, nicht von dieser App, und lässt sich in Deck unter *Fälligkeitserinnerungen* abschalten.
|
||||
|
||||
## Wenn Board oder Stapel gelöscht werden
|
||||
|
||||
- **In der Übersicht** wird der Workflow rot markiert, zum Beispiel „Board nicht mehr vorhanden“ oder „Quell-Stapel nicht mehr vorhanden“.
|
||||
- **Beim nächsten Durchlauf** wird der Workflow automatisch deaktiviert. Der Eigentümer bekommt einmalig eine E-Mail und, wenn die App *Benachrichtigungen* aktiv ist, eine Nextcloud-Benachrichtigung. Das gilt unabhängig davon, was für verschobene Karten eingestellt ist. Danach kommt keine weitere Erinnerung.
|
||||
- **Archivierte Boards** deaktivieren den Workflow nicht. Sie lassen sich nur nicht mehr neu auswählen.
|
||||
- **Ist Deck vorübergehend nicht erreichbar**, passiert nichts: Der Workflow wird übersprungen und beim nächsten Durchlauf erneut versucht.
|
||||
|
||||
Um einen deaktivierten Workflow wieder zu nutzen, stellst du ein vorhandenes Board und vorhandene Stapel ein und aktivierst ihn wieder.
|
||||
|
||||
## Sprache
|
||||
|
||||
Die App ist für den internen Einsatz gedacht und durchgehend **deutsch**: Oberfläche, Fehlermeldungen, E-Mails und Benachrichtigungen.
|
||||
|
||||
## Lizenz
|
||||
|
||||
|
||||
+12
-14
@@ -7,40 +7,38 @@
|
||||
<summary lang="de">Verschiebt überfällige Deck-Karten automatisch anhand konfigurierbarer Regeln</summary>
|
||||
<summary lang="en">Automatically moves overdue Deck cards based on configurable rules</summary>
|
||||
<description lang="de"><![CDATA[
|
||||
Diese App erlaubt es jedem Nutzer, in den persönlichen Einstellungen eigene Automatisierungs-Workflows für die Deck-App anzulegen:
|
||||
Diese App erlaubt es jedem Nutzer, in den persönlichen Einstellungen Automatisierungs-Workflows für seine eigenen Deck-Boards anzulegen:
|
||||
|
||||
* Quell-Board und Quell-Stapel wählen
|
||||
* Ziel-Stapel wählen, in den überfällige Karten verschoben werden
|
||||
* optional nach zugewiesenen Benutzern filtern
|
||||
* optional nach Labels/Tags filtern
|
||||
* optional eine E-Mail-Benachrichtigung an sich selbst aktivieren
|
||||
* optional benachrichtigen, per E-Mail und/oder als Nextcloud-Benachrichtigung (Push): nur sich selbst oder alle Board-Mitglieder gemäß ihrer Deck-Benachrichtigungseinstellung
|
||||
|
||||
Ein Hintergrundjob prüft regelmäßig alle aktiven Workflows und verschiebt Karten, deren Fälligkeitsdatum überschritten wurde, ausschließlich über die internen PHP-Klassen der Deck-App (keine HTTP/OCS-Aufrufe).
|
||||
Ein Hintergrundjob prüft regelmäßig alle aktiven Workflows und verschiebt Karten, deren Fälligkeitsdatum überschritten wurde.
|
||||
]]></description>
|
||||
<description lang="en"><![CDATA[
|
||||
This app lets every user configure their own Deck automation workflows from Personal settings:
|
||||
This app lets every user configure Deck automation workflows for the boards they own from Personal settings:
|
||||
|
||||
* pick a source board and source stack
|
||||
* pick a destination stack overdue cards get moved to
|
||||
* optionally filter by assigned users
|
||||
* optionally filter by labels/tags
|
||||
* optionally enable an email notification to themselves
|
||||
* optionally notify by email and/or Nextcloud notification (push): themselves only, or every board member according to their Deck notification setting
|
||||
|
||||
A background job periodically evaluates all enabled workflows and moves cards whose due date has passed, using Deck's own internal PHP classes only (no HTTP/OCS calls).
|
||||
A background job periodically evaluates all enabled workflows and moves cards whose due date has passed.
|
||||
]]></description>
|
||||
<version>0.1.0</version>
|
||||
<version>35.3.0</version>
|
||||
<licence>agpl</licence>
|
||||
<author mail="patrick@niebel.ing">Patrick Niebeling</author>
|
||||
<namespace>WorkflowDeckAutomation</namespace>
|
||||
<category>organization</category>
|
||||
<category>tools</category>
|
||||
<bugs>https://github.com/pniebeling/workflow_deck_automation/issues</bugs>
|
||||
<website>https://gitea.gnilebein.de/gnilebein/nextcloud-workflow-deck-automation</website>
|
||||
<bugs>https://gitea.gnilebein.de/gnilebein/nextcloud-workflow-deck-automation/issues</bugs>
|
||||
<repository type="git">https://gitea.gnilebein.de/gnilebein/nextcloud-workflow-deck-automation.git</repository>
|
||||
<dependencies>
|
||||
<php min-version="8.2" max-version="8.4"/>
|
||||
<php min-version="8.4" max-version="8.5"/>
|
||||
<database min-version="9.4">pgsql</database>
|
||||
<database>sqlite</database>
|
||||
<database min-version="8.0">mysql</database>
|
||||
<nextcloud min-version="34" max-version="34"/>
|
||||
<nextcloud min-version="35" max-version="35"/>
|
||||
</dependencies>
|
||||
<background-jobs>
|
||||
<job>OCA\WorkflowDeckAutomation\BackgroundJob\RunWorkflowsJob</job>
|
||||
|
||||
@@ -11,7 +11,5 @@ return [
|
||||
|
||||
['name' => 'workflow#boards', 'url' => '/api/v1/boards', 'verb' => 'GET'],
|
||||
['name' => 'workflow#stacks', 'url' => '/api/v1/boards/{boardId}/stacks', 'verb' => 'GET'],
|
||||
['name' => 'workflow#labels', 'url' => '/api/v1/boards/{boardId}/labels', 'verb' => 'GET'],
|
||||
['name' => 'workflow#participants', 'url' => '/api/v1/boards/{boardId}/participants', 'verb' => 'GET'],
|
||||
],
|
||||
];
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace OCA\WorkflowDeckAutomation\AppInfo;
|
||||
|
||||
use OCA\WorkflowDeckAutomation\Notification\Notifier;
|
||||
use OCP\AppFramework\App;
|
||||
use OCP\AppFramework\Bootstrap\IBootContext;
|
||||
use OCP\AppFramework\Bootstrap\IBootstrap;
|
||||
@@ -16,7 +17,13 @@ class Application extends App implements IBootstrap {
|
||||
parent::__construct(self::APP_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* Background job and settings are declared in info.xml; the notifier is
|
||||
* the one thing that has to be registered here. Without the
|
||||
* notifications app it is simply never asked.
|
||||
*/
|
||||
public function register(IRegistrationContext $context): void {
|
||||
$context->registerNotifierService(Notifier::class);
|
||||
}
|
||||
|
||||
public function boot(IBootContext $context): void {
|
||||
|
||||
@@ -6,51 +6,47 @@ namespace OCA\WorkflowDeckAutomation\BackgroundJob;
|
||||
|
||||
use OCA\WorkflowDeckAutomation\Service\WorkflowRunner;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
use OCP\BackgroundJob\IJob;
|
||||
use OCP\BackgroundJob\TimedJob;
|
||||
use OCP\IConfig;
|
||||
|
||||
class RunWorkflowsJob extends TimedJob {
|
||||
/**
|
||||
* config.php key holding how often (in seconds) workflows are checked.
|
||||
*/
|
||||
public const INTERVAL_CONFIG_KEY = 'workflow_deck_automation.interval';
|
||||
|
||||
public const DEFAULT_INTERVAL = 5 * 60;
|
||||
use OCP\BackgroundJob\Job;
|
||||
|
||||
/**
|
||||
* Nextcloud's cron itself only ticks every 5 minutes by default, so
|
||||
* anything below a minute would only add load without running more
|
||||
* often. Values under this are clamped rather than rejected.
|
||||
* Evaluates all enabled workflows, once per cron pass.
|
||||
*
|
||||
* Deliberately a plain `Job`, not a `TimedJob`: there is no interval to
|
||||
* configure and none to compare against, so the cadence is exactly
|
||||
* Nextcloud's cron cadence — every pass, no matter how often cron is set
|
||||
* up to run. `TimedJob` with `setInterval(0)` would behave almost the
|
||||
* same, but it keeps an interval concept around that nothing uses.
|
||||
*
|
||||
* **Time sensitivity still matters, and it comes for free here.**
|
||||
* `OC\Core\Service\CronService::runCli()` reads `maintenance_window_start`
|
||||
* (default `100`, i.e. unset) and, whenever the current UTC hour is outside
|
||||
* `[start, start+4]`, asks `JobList::getNext($onlyTimeSensitive = true)`,
|
||||
* which filters on the `time_sensitive` *column* of `oc_jobs`. A job stamped
|
||||
* insensitive is then skipped for the other 20 hours of the day — this app
|
||||
* shipped that way once and the job looked completely dead, showing
|
||||
* `last_run = 1970-01-01` in `occ background-job:list` indefinitely. Note
|
||||
* the gate only exists in `runCli()`, so ajax/webcron are unaffected and
|
||||
* `occ background-job:worker '<class>'` bypasses it, which is why manual
|
||||
* testing can look fine while cron does nothing.
|
||||
*
|
||||
* A plain `Job` can never acquire that stamp: `JobList::add()` leaves the
|
||||
* column at its `TIME_SENSITIVE` default, and the one place that ever
|
||||
* downgrades it, `JobList::setLastRun()`, is guarded by
|
||||
* `$job instanceof TimedJob && !$job->isTimeSensitive()` — with no `else`.
|
||||
* Rows written by an older version of this app keep whatever they were
|
||||
* stamped with, though, so an instance that ran the `TIME_INSENSITIVE`
|
||||
* version still needs the one-off `UPDATE oc_jobs SET time_sensitive = 0`
|
||||
* documented in the README.
|
||||
*/
|
||||
public const MINIMUM_INTERVAL = 60;
|
||||
|
||||
class RunWorkflowsJob extends Job {
|
||||
public function __construct(
|
||||
ITimeFactory $time,
|
||||
IConfig $config,
|
||||
private WorkflowRunner $runner,
|
||||
) {
|
||||
parent::__construct($time);
|
||||
// TimedJob compares against this value on every cron pass, so a
|
||||
// changed config.php takes effect on the next pass — no occ command
|
||||
// and no re-registration of the job needed.
|
||||
$this->setInterval(self::resolveInterval($config));
|
||||
// Must stay TIME_SENSITIVE. If `maintenance_window_start` is set in
|
||||
// config.php — and Nextcloud's admin overview actively nags admins
|
||||
// to set it — CronService only picks up TIME_INSENSITIVE jobs during
|
||||
// those 4 hours and skips them for the rest of the day. A job whose
|
||||
// whole point is a 5-minute (configurable down to 60s) reaction time
|
||||
// would then run a handful of times per night and look completely
|
||||
// dead in between.
|
||||
$this->setTimeSensitivity(IJob::TIME_SENSITIVE);
|
||||
$this->setAllowParallelRuns(false);
|
||||
}
|
||||
|
||||
private static function resolveInterval(IConfig $config): int {
|
||||
$interval = $config->getSystemValueInt(self::INTERVAL_CONFIG_KEY, self::DEFAULT_INTERVAL);
|
||||
return max(self::MINIMUM_INTERVAL, $interval);
|
||||
}
|
||||
|
||||
protected function run($argument): void {
|
||||
$this->runner->run();
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use OCA\WorkflowDeckAutomation\Db\Workflow;
|
||||
use OCA\WorkflowDeckAutomation\Db\WorkflowMapper;
|
||||
use OCA\WorkflowDeckAutomation\Service\DeckIntegrationService;
|
||||
use OCA\WorkflowDeckAutomation\Service\DeckUnavailableException;
|
||||
use OCA\WorkflowDeckAutomation\Service\NotificationPolicy;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||
@@ -33,46 +34,42 @@ class WorkflowController extends OCSController {
|
||||
private function currentUserId(): string {
|
||||
$user = $this->userSession->getUser();
|
||||
if ($user === null) {
|
||||
throw new OCSPreconditionFailedException('Not logged in');
|
||||
throw new OCSPreconditionFailedException('Nicht angemeldet');
|
||||
}
|
||||
return $user->getUID();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every workflow on a board the user owns, no matter who created it: a
|
||||
* workflow belongs to its board's owner, and follows the board when
|
||||
* Deck transfers it.
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
public function index(): DataResponse {
|
||||
$workflows = $this->workflowMapper->findAllForUser($this->currentUserId());
|
||||
$workflows = $this->workflowMapper->findAllForBoards($this->ownedBoardIds());
|
||||
return new DataResponse(array_map(static fn (Workflow $w) => $w->jsonSerialize(), $workflows));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $filterUserIds
|
||||
* @param int[] $filterLabelIds
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
public function create(
|
||||
string $title,
|
||||
int $boardId,
|
||||
int $sourceStackId,
|
||||
int $targetStackId,
|
||||
array $filterUserIds = [],
|
||||
array $filterLabelIds = [],
|
||||
bool $notifyEmail = false,
|
||||
string $notifyMode = NotificationPolicy::MODE_OFF,
|
||||
string $notifyChannel = NotificationPolicy::CHANNEL_MAIL,
|
||||
bool $enabled = true,
|
||||
): DataResponse {
|
||||
$this->validate($title, $boardId, $sourceStackId, $targetStackId);
|
||||
$this->validate($title, $boardId, $sourceStackId, $targetStackId, $notifyMode, $notifyChannel);
|
||||
|
||||
$workflow = new Workflow();
|
||||
$workflow->setUserId($this->currentUserId());
|
||||
$this->applyFields($workflow, $title, $boardId, $sourceStackId, $targetStackId, $filterUserIds, $filterLabelIds, $notifyEmail, $enabled);
|
||||
$this->applyFields($workflow, $title, $boardId, $sourceStackId, $targetStackId, $notifyMode, $notifyChannel, $enabled);
|
||||
|
||||
$workflow = $this->workflowMapper->insert($workflow);
|
||||
return new DataResponse($workflow->jsonSerialize(), Http::STATUS_CREATED);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $filterUserIds
|
||||
* @param int[] $filterLabelIds
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
public function update(
|
||||
int $id,
|
||||
@@ -80,20 +77,17 @@ class WorkflowController extends OCSController {
|
||||
int $boardId,
|
||||
int $sourceStackId,
|
||||
int $targetStackId,
|
||||
array $filterUserIds = [],
|
||||
array $filterLabelIds = [],
|
||||
bool $notifyEmail = false,
|
||||
string $notifyMode = NotificationPolicy::MODE_OFF,
|
||||
string $notifyChannel = NotificationPolicy::CHANNEL_MAIL,
|
||||
bool $enabled = true,
|
||||
): DataResponse {
|
||||
$this->validate($title, $boardId, $sourceStackId, $targetStackId);
|
||||
$workflow = $this->findOwnWorkflow($id);
|
||||
$this->validate($title, $boardId, $sourceStackId, $targetStackId, $notifyMode, $notifyChannel);
|
||||
|
||||
try {
|
||||
$workflow = $this->workflowMapper->findForUser($id, $this->currentUserId());
|
||||
} catch (DoesNotExistException $e) {
|
||||
throw new OCSNotFoundException('Workflow not found');
|
||||
}
|
||||
|
||||
$this->applyFields($workflow, $title, $boardId, $sourceStackId, $targetStackId, $filterUserIds, $filterLabelIds, $notifyEmail, $enabled);
|
||||
// Kept in step with the board owner so the job runs as, and mails,
|
||||
// the right user; validate() made sure the editor owns the board.
|
||||
$workflow->setUserId($this->currentUserId());
|
||||
$this->applyFields($workflow, $title, $boardId, $sourceStackId, $targetStackId, $notifyMode, $notifyChannel, $enabled);
|
||||
$workflow = $this->workflowMapper->update($workflow);
|
||||
|
||||
return new DataResponse($workflow->jsonSerialize());
|
||||
@@ -101,12 +95,7 @@ class WorkflowController extends OCSController {
|
||||
|
||||
#[NoAdminRequired]
|
||||
public function destroy(int $id): DataResponse {
|
||||
try {
|
||||
$workflow = $this->workflowMapper->findForUser($id, $this->currentUserId());
|
||||
} catch (DoesNotExistException $e) {
|
||||
throw new OCSNotFoundException('Workflow not found');
|
||||
}
|
||||
|
||||
$workflow = $this->findOwnWorkflow($id);
|
||||
$this->workflowMapper->delete($workflow);
|
||||
return new DataResponse([]);
|
||||
}
|
||||
@@ -114,7 +103,7 @@ class WorkflowController extends OCSController {
|
||||
#[NoAdminRequired]
|
||||
public function boards(): DataResponse {
|
||||
$this->assertDeck();
|
||||
return new DataResponse($this->deckService->listBoardsForCurrentUser());
|
||||
return new DataResponse($this->ownedBoards());
|
||||
}
|
||||
|
||||
#[NoAdminRequired]
|
||||
@@ -123,36 +112,64 @@ class WorkflowController extends OCSController {
|
||||
return new DataResponse($this->deckService->listStacks($boardId));
|
||||
}
|
||||
|
||||
#[NoAdminRequired]
|
||||
public function labels(int $boardId): DataResponse {
|
||||
$this->assertDeck();
|
||||
return new DataResponse($this->deckService->listLabels($boardId));
|
||||
private function findOwnWorkflow(int $id): Workflow {
|
||||
try {
|
||||
$workflow = $this->workflowMapper->findById($id);
|
||||
} catch (DoesNotExistException $e) {
|
||||
throw new OCSNotFoundException('Workflow nicht gefunden');
|
||||
}
|
||||
|
||||
#[NoAdminRequired]
|
||||
public function participants(int $boardId): DataResponse {
|
||||
if (!in_array($workflow->getBoardId(), $this->ownedBoardIds(), true)) {
|
||||
// Same answer as for a missing id: don't reveal other users' workflows.
|
||||
throw new OCSNotFoundException('Workflow nicht gefunden');
|
||||
}
|
||||
return $workflow;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{id: int, title: string, archived: bool}>
|
||||
*/
|
||||
private function ownedBoards(): array {
|
||||
$userId = $this->currentUserId();
|
||||
$this->assertDeck();
|
||||
return new DataResponse($this->deckService->listParticipants($boardId));
|
||||
try {
|
||||
return $this->deckService->listOwnedBoards($userId);
|
||||
} catch (DeckUnavailableException $e) {
|
||||
throw new OCSPreconditionFailedException('Deine Deck-Boards konnten nicht geladen werden: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int[]
|
||||
*/
|
||||
private function ownedBoardIds(): array {
|
||||
return array_map(static fn (array $board) => $board['id'], $this->ownedBoards());
|
||||
}
|
||||
|
||||
private function assertDeck(): void {
|
||||
$user = $this->userSession->getUser();
|
||||
if ($user === null) {
|
||||
throw new OCSPreconditionFailedException('Not logged in');
|
||||
throw new OCSPreconditionFailedException('Nicht angemeldet');
|
||||
}
|
||||
try {
|
||||
$this->deckService->assertDeckAvailable($user);
|
||||
} catch (DeckUnavailableException $e) {
|
||||
throw new OCSPreconditionFailedException('Deck is not available: ' . $e->getMessage());
|
||||
throw new OCSPreconditionFailedException('Deck ist nicht verfügbar: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function validate(string $title, int $boardId, int $sourceStackId, int $targetStackId): void {
|
||||
private function validate(string $title, int $boardId, int $sourceStackId, int $targetStackId, string $notifyMode, string $notifyChannel): void {
|
||||
if (trim($title) === '') {
|
||||
throw new OCSBadRequestException('Title must not be empty');
|
||||
throw new OCSBadRequestException('Bitte einen Titel angeben.');
|
||||
}
|
||||
if ($sourceStackId === $targetStackId) {
|
||||
throw new OCSBadRequestException('Source and target stack must differ');
|
||||
throw new OCSBadRequestException('Quell- und Ziel-Stapel müssen sich unterscheiden.');
|
||||
}
|
||||
if (!NotificationPolicy::isValidMode($notifyMode)) {
|
||||
throw new OCSBadRequestException('Ungültige Benachrichtigungs-Einstellung, erlaubt sind: ' . implode(', ', NotificationPolicy::MODES));
|
||||
}
|
||||
if (!NotificationPolicy::isValidChannel($notifyChannel)) {
|
||||
throw new OCSBadRequestException('Ungültiger Benachrichtigungsweg, erlaubt sind: ' . implode(', ', NotificationPolicy::CHANNELS));
|
||||
}
|
||||
|
||||
// Reject references to boards or stacks that are gone (or were never
|
||||
@@ -161,12 +178,18 @@ class WorkflowController extends OCSController {
|
||||
// not "Deck is unreachable".
|
||||
$this->assertDeck();
|
||||
|
||||
$boardIds = array_map(
|
||||
static fn (array $board) => (int)$board['id'],
|
||||
$this->deckService->listBoardsForCurrentUser(),
|
||||
);
|
||||
if (!in_array($boardId, $boardIds, true)) {
|
||||
throw new OCSBadRequestException('The selected board does not exist or is not accessible');
|
||||
// Only boards the user owns are listed, so this also enforces the
|
||||
// owner rule. Archived boards stay listed (their workflows must stay
|
||||
// visible) but are no target for a new or changed workflow.
|
||||
$board = null;
|
||||
foreach ($this->ownedBoards() as $candidate) {
|
||||
if ($candidate['id'] === $boardId) {
|
||||
$board = $candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($board === null || $board['archived']) {
|
||||
throw new OCSBadRequestException('Das gewählte Board existiert nicht, ist archiviert oder gehört dir nicht.');
|
||||
}
|
||||
|
||||
$stackIds = array_map(
|
||||
@@ -174,32 +197,26 @@ class WorkflowController extends OCSController {
|
||||
$this->deckService->listStacks($boardId),
|
||||
);
|
||||
if (!in_array($sourceStackId, $stackIds, true) || !in_array($targetStackId, $stackIds, true)) {
|
||||
throw new OCSBadRequestException('The selected stack does not exist on this board');
|
||||
throw new OCSBadRequestException('Der gewählte Stapel existiert auf diesem Board nicht.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $filterUserIds
|
||||
* @param int[] $filterLabelIds
|
||||
*/
|
||||
private function applyFields(
|
||||
Workflow $workflow,
|
||||
string $title,
|
||||
int $boardId,
|
||||
int $sourceStackId,
|
||||
int $targetStackId,
|
||||
array $filterUserIds,
|
||||
array $filterLabelIds,
|
||||
bool $notifyEmail,
|
||||
string $notifyMode,
|
||||
string $notifyChannel,
|
||||
bool $enabled,
|
||||
): void {
|
||||
$workflow->setTitle($title);
|
||||
$workflow->setBoardId($boardId);
|
||||
$workflow->setSourceStackId($sourceStackId);
|
||||
$workflow->setTargetStackId($targetStackId);
|
||||
$workflow->setFilterUserIds($filterUserIds === [] ? null : json_encode(array_values($filterUserIds)));
|
||||
$workflow->setFilterLabelIds($filterLabelIds === [] ? null : json_encode(array_values($filterLabelIds)));
|
||||
$workflow->setNotifyEmail($notifyEmail);
|
||||
$workflow->setNotifyMode($notifyMode);
|
||||
$workflow->setNotifyChannel($notifyChannel);
|
||||
$workflow->setEnabled($enabled);
|
||||
}
|
||||
}
|
||||
+10
-40
@@ -18,12 +18,10 @@ use OCP\DB\Types;
|
||||
* @method void setSourceStackId(int $sourceStackId)
|
||||
* @method int getTargetStackId()
|
||||
* @method void setTargetStackId(int $targetStackId)
|
||||
* @method string|null getFilterUserIds()
|
||||
* @method void setFilterUserIds(?string $filterUserIds)
|
||||
* @method string|null getFilterLabelIds()
|
||||
* @method void setFilterLabelIds(?string $filterLabelIds)
|
||||
* @method bool getNotifyEmail()
|
||||
* @method void setNotifyEmail(bool $notifyEmail)
|
||||
* @method string getNotifyMode()
|
||||
* @method void setNotifyMode(string $notifyMode)
|
||||
* @method string getNotifyChannel()
|
||||
* @method void setNotifyChannel(string $notifyChannel)
|
||||
* @method bool getEnabled()
|
||||
* @method void setEnabled(bool $enabled)
|
||||
* @method \DateTime|null getLastRun()
|
||||
@@ -35,9 +33,8 @@ class Workflow extends Entity implements \JsonSerializable {
|
||||
protected $boardId;
|
||||
protected $sourceStackId;
|
||||
protected $targetStackId;
|
||||
protected $filterUserIds;
|
||||
protected $filterLabelIds;
|
||||
protected $notifyEmail;
|
||||
protected $notifyMode;
|
||||
protected $notifyChannel;
|
||||
protected $enabled;
|
||||
protected $lastRun;
|
||||
|
||||
@@ -48,38 +45,12 @@ class Workflow extends Entity implements \JsonSerializable {
|
||||
$this->addType('boardId', Types::INTEGER);
|
||||
$this->addType('sourceStackId', Types::INTEGER);
|
||||
$this->addType('targetStackId', Types::INTEGER);
|
||||
$this->addType('filterUserIds', Types::STRING);
|
||||
$this->addType('filterLabelIds', Types::STRING);
|
||||
$this->addType('notifyEmail', Types::BOOLEAN);
|
||||
$this->addType('notifyMode', Types::STRING);
|
||||
$this->addType('notifyChannel', Types::STRING);
|
||||
$this->addType('enabled', Types::BOOLEAN);
|
||||
$this->addType('lastRun', Types::DATETIME);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getFilterUserIdsArray(): array {
|
||||
return $this->decodeIds($this->getFilterUserIds());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int[]
|
||||
*/
|
||||
public function getFilterLabelIdsArray(): array {
|
||||
return array_map('intval', $this->decodeIds($this->getFilterLabelIds()));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
private function decodeIds(?string $json): array {
|
||||
if ($json === null || $json === '') {
|
||||
return [];
|
||||
}
|
||||
$decoded = json_decode($json, true);
|
||||
return is_array($decoded) ? array_values($decoded) : [];
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array {
|
||||
return [
|
||||
'id' => $this->getId(),
|
||||
@@ -88,9 +59,8 @@ class Workflow extends Entity implements \JsonSerializable {
|
||||
'boardId' => $this->getBoardId(),
|
||||
'sourceStackId' => $this->getSourceStackId(),
|
||||
'targetStackId' => $this->getTargetStackId(),
|
||||
'filterUserIds' => $this->getFilterUserIdsArray(),
|
||||
'filterLabelIds' => $this->getFilterLabelIdsArray(),
|
||||
'notifyEmail' => (bool)$this->getNotifyEmail(),
|
||||
'notifyMode' => (string)$this->getNotifyMode(),
|
||||
'notifyChannel' => (string)$this->getNotifyChannel(),
|
||||
'enabled' => (bool)$this->getEnabled(),
|
||||
'lastRun' => $this->getLastRun()?->format(\DateTimeInterface::ATOM),
|
||||
];
|
||||
|
||||
@@ -22,24 +22,27 @@ class WorkflowMapper extends QBMapper {
|
||||
* @throws DoesNotExistException
|
||||
* @throws MultipleObjectsReturnedException
|
||||
*/
|
||||
public function findForUser(int $id, string $userId): Workflow {
|
||||
public function findById(int $id): Workflow {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)))
|
||||
->andWhere($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)));
|
||||
->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)));
|
||||
|
||||
return $this->findEntity($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int[] $boardIds
|
||||
* @return Workflow[]
|
||||
*/
|
||||
public function findAllForUser(string $userId): array {
|
||||
public function findAllForBoards(array $boardIds): array {
|
||||
if ($boardIds === []) {
|
||||
return [];
|
||||
}
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)))
|
||||
->where($qb->expr()->in('board_id', $qb->createNamedParameter($boardIds, IQueryBuilder::PARAM_INT_ARRAY)))
|
||||
->orderBy('id', 'ASC');
|
||||
|
||||
return $this->findEntities($qb);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\WorkflowDeckAutomation\Migration;
|
||||
|
||||
use Closure;
|
||||
use OCP\DB\ISchemaWrapper;
|
||||
use OCP\Migration\IOutput;
|
||||
use OCP\Migration\SimpleMigrationStep;
|
||||
|
||||
/**
|
||||
* Drops the assigned-user and label filters. Workflows that had one keep
|
||||
* running, now unfiltered — that was an explicit product decision.
|
||||
*
|
||||
* The columns have to go rather than just be ignored: Entity::fromRow()
|
||||
* calls a setter for every selected column, and the Workflow entity no
|
||||
* longer has these properties.
|
||||
*/
|
||||
class Version1001Date20260930120000 extends SimpleMigrationStep {
|
||||
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
|
||||
/** @var ISchemaWrapper $schema */
|
||||
$schema = $schemaClosure();
|
||||
|
||||
if (!$schema->hasTable('wfda_workflows')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$table = $schema->getTable('wfda_workflows');
|
||||
$changed = false;
|
||||
foreach (['filter_user_ids', 'filter_label_ids'] as $column) {
|
||||
if ($table->hasColumn($column)) {
|
||||
$table->dropColumn($column);
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $changed ? $schema : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\WorkflowDeckAutomation\Migration;
|
||||
|
||||
use Closure;
|
||||
use OCP\DB\ISchemaWrapper;
|
||||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\DB\Types;
|
||||
use OCP\IDBConnection;
|
||||
use OCP\Migration\IOutput;
|
||||
use OCP\Migration\SimpleMigrationStep;
|
||||
|
||||
/**
|
||||
* Replaces the yes/no `notify_email` flag with `notify_mode`
|
||||
* (off / owner / board, see NotificationPolicy::MODES).
|
||||
*
|
||||
* Existing workflows keep behaving exactly as before: a set flag becomes
|
||||
* `owner` — the only recipient the flag ever had — everything else `off`.
|
||||
* The old column is dropped by the next step, once the values are copied.
|
||||
*/
|
||||
class Version1002Date20261001120000 extends SimpleMigrationStep {
|
||||
public function __construct(
|
||||
private IDBConnection $db,
|
||||
) {
|
||||
}
|
||||
|
||||
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
|
||||
/** @var ISchemaWrapper $schema */
|
||||
$schema = $schemaClosure();
|
||||
|
||||
if (!$schema->hasTable('wfda_workflows')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$table = $schema->getTable('wfda_workflows');
|
||||
if ($table->hasColumn('notify_mode')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$table->addColumn('notify_mode', Types::STRING, [
|
||||
'notnull' => true,
|
||||
'length' => 16,
|
||||
'default' => 'off',
|
||||
]);
|
||||
|
||||
return $schema;
|
||||
}
|
||||
|
||||
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
|
||||
/** @var ISchemaWrapper $schema */
|
||||
$schema = $schemaClosure();
|
||||
if (!$schema->hasTable('wfda_workflows') || !$schema->getTable('wfda_workflows')->hasColumn('notify_email')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->update('wfda_workflows')
|
||||
->set('notify_mode', $qb->createNamedParameter('owner'))
|
||||
->where($qb->expr()->eq('notify_email', $qb->createNamedParameter(true, IQueryBuilder::PARAM_BOOL)));
|
||||
$qb->executeStatement();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\WorkflowDeckAutomation\Migration;
|
||||
|
||||
use Closure;
|
||||
use OCP\DB\ISchemaWrapper;
|
||||
use OCP\Migration\IOutput;
|
||||
use OCP\Migration\SimpleMigrationStep;
|
||||
|
||||
/**
|
||||
* Drops `notify_email` now that Version1002Date20261001120000 has copied it
|
||||
* into `notify_mode`. It has to go rather than be ignored: Entity::fromRow()
|
||||
* calls a setter per selected column, and Workflow no longer has one.
|
||||
*/
|
||||
class Version1003Date20261001120100 extends SimpleMigrationStep {
|
||||
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
|
||||
/** @var ISchemaWrapper $schema */
|
||||
$schema = $schemaClosure();
|
||||
|
||||
if (!$schema->hasTable('wfda_workflows')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$table = $schema->getTable('wfda_workflows');
|
||||
if (!$table->hasColumn('notify_email')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$table->dropColumn('notify_email');
|
||||
return $schema;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\WorkflowDeckAutomation\Migration;
|
||||
|
||||
use Closure;
|
||||
use OCP\DB\ISchemaWrapper;
|
||||
use OCP\DB\Types;
|
||||
use OCP\Migration\IOutput;
|
||||
use OCP\Migration\SimpleMigrationStep;
|
||||
|
||||
/**
|
||||
* Adds `notify_channel` (mail / notification / both, see
|
||||
* NotificationPolicy::CHANNELS): how the recipients chosen by `notify_mode`
|
||||
* hear about moved cards. Existing workflows get `mail`, the only channel
|
||||
* there was, so nothing changes for them.
|
||||
*/
|
||||
class Version1004Date20261002120000 extends SimpleMigrationStep {
|
||||
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
|
||||
/** @var ISchemaWrapper $schema */
|
||||
$schema = $schemaClosure();
|
||||
|
||||
if (!$schema->hasTable('wfda_workflows')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$table = $schema->getTable('wfda_workflows');
|
||||
if ($table->hasColumn('notify_channel')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$table->addColumn('notify_channel', Types::STRING, [
|
||||
'notnull' => true,
|
||||
'length' => 16,
|
||||
'default' => 'mail',
|
||||
]);
|
||||
|
||||
return $schema;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\WorkflowDeckAutomation\Notification;
|
||||
|
||||
use OCA\WorkflowDeckAutomation\AppInfo\Application;
|
||||
use OCA\WorkflowDeckAutomation\Service\NotificationMailer;
|
||||
use OCA\WorkflowDeckAutomation\Service\NotificationPusher;
|
||||
use OCP\IURLGenerator;
|
||||
use OCP\Notification\INotification;
|
||||
use OCP\Notification\INotifier;
|
||||
use OCP\Notification\UnknownNotificationException;
|
||||
|
||||
/**
|
||||
* Turns the values NotificationPusher stored into text. German only, like
|
||||
* the rest of the app — the language Nextcloud asks for is ignored.
|
||||
*
|
||||
* Deliberately asks Deck nothing: the stored titles are the ones from the
|
||||
* moment the card moved, and a Deck lookup here would run as whoever
|
||||
* happens to fetch notifications (see CLAUDE.md on Deck's frozen uid).
|
||||
*/
|
||||
class Notifier implements INotifier {
|
||||
public function __construct(
|
||||
private IURLGenerator $urlGenerator,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getID(): string {
|
||||
return Application::APP_ID;
|
||||
}
|
||||
|
||||
public function getName(): string {
|
||||
return 'Deck Workflow-Automatisierung';
|
||||
}
|
||||
|
||||
public function prepare(INotification $notification, string $languageCode): INotification {
|
||||
if ($notification->getApp() !== Application::APP_ID) {
|
||||
throw new UnknownNotificationException();
|
||||
}
|
||||
|
||||
$notification->setIcon($this->urlGenerator->getAbsoluteURL($this->urlGenerator->imagePath(Application::APP_ID, 'app-dark.svg')));
|
||||
$params = $notification->getSubjectParameters();
|
||||
|
||||
switch ($notification->getSubject()) {
|
||||
case NotificationPusher::SUBJECT_CARD_MOVED:
|
||||
$this->prepareCardMoved($notification, $params);
|
||||
return $notification;
|
||||
case NotificationPusher::SUBJECT_WORKFLOW_DISABLED:
|
||||
$this->prepareWorkflowDisabled($notification, $params);
|
||||
return $notification;
|
||||
default:
|
||||
throw new UnknownNotificationException();
|
||||
}
|
||||
}
|
||||
|
||||
private function prepareCardMoved(INotification $notification, array $params): void {
|
||||
$cardId = $notification->getObjectId();
|
||||
$boardId = (string)($params['boardId'] ?? '');
|
||||
$boardTitle = (string)($params['boardTitle'] ?? '');
|
||||
$targetStackTitle = (string)($params['targetStackTitle'] ?? '');
|
||||
$cardUrl = $this->urlGenerator->getAbsoluteURL('/apps/deck/board/' . $boardId . '/card/' . $cardId);
|
||||
|
||||
// Every title is user input and goes in as a rich object, never into
|
||||
// the template itself — a title containing "{…}" would otherwise be
|
||||
// read as a placeholder.
|
||||
$objects = [
|
||||
'deck-card' => [
|
||||
'type' => 'deck-card',
|
||||
'id' => $cardId,
|
||||
'name' => (string)($params['cardTitle'] ?? ''),
|
||||
'boardname' => $boardTitle,
|
||||
'stackname' => $targetStackTitle,
|
||||
'link' => $cardUrl,
|
||||
],
|
||||
'deck-board' => [
|
||||
'type' => 'deck-board',
|
||||
'id' => $boardId,
|
||||
'name' => $boardTitle,
|
||||
'link' => $this->urlGenerator->getAbsoluteURL('/apps/deck/board/' . $boardId),
|
||||
],
|
||||
'source-stack' => [
|
||||
'type' => 'highlight',
|
||||
'id' => (string)($params['sourceStackId'] ?? ''),
|
||||
'name' => (string)($params['sourceStackTitle'] ?? ''),
|
||||
],
|
||||
'target-stack' => [
|
||||
'type' => 'highlight',
|
||||
'id' => (string)($params['targetStackId'] ?? ''),
|
||||
'name' => $targetStackTitle,
|
||||
],
|
||||
];
|
||||
|
||||
$notification->setLink($cardUrl);
|
||||
self::setText(
|
||||
$notification,
|
||||
'{deck-card} wurde nach {target-stack} verschoben',
|
||||
'Die Karte auf {deck-board} ist überfällig und wurde automatisch von {source-stack} nach {target-stack} verschoben.',
|
||||
$objects,
|
||||
);
|
||||
}
|
||||
|
||||
private function prepareWorkflowDisabled(INotification $notification, array $params): void {
|
||||
$settingsUrl = $this->urlGenerator->getAbsoluteURL('/settings/user/' . Application::APP_ID);
|
||||
|
||||
$objects = [
|
||||
'workflow' => [
|
||||
'type' => 'highlight',
|
||||
'id' => $notification->getObjectId(),
|
||||
'name' => (string)($params['workflowTitle'] ?? ''),
|
||||
'link' => $settingsUrl,
|
||||
],
|
||||
];
|
||||
|
||||
$notification->setLink($settingsUrl);
|
||||
self::setText(
|
||||
$notification,
|
||||
'Workflow {workflow} wurde deaktiviert',
|
||||
sprintf(
|
||||
'Er wurde automatisch ausgeschaltet, weil %s, und verschiebt keine Karten mehr. Lösche ihn oder stelle ein vorhandenes Board und vorhandene Stapel ein, um ihn wieder zu aktivieren.',
|
||||
NotificationMailer::describeBrokenTarget((string)($params['brokenTarget'] ?? '')),
|
||||
),
|
||||
$objects,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the rich texts and their plain fallback (used by clients without
|
||||
* rich text support), with titles in German quotes there.
|
||||
*
|
||||
* @param array<string, array{name: string}> $objects
|
||||
*/
|
||||
private static function setText(INotification $notification, string $subject, string $message, array $objects): void {
|
||||
$plain = [];
|
||||
foreach ($objects as $key => $object) {
|
||||
$plain['{' . $key . '}'] = '„' . $object['name'] . '“';
|
||||
}
|
||||
|
||||
// strtr() replaces in a single pass, so a title that itself contains
|
||||
// a placeholder is left as it is.
|
||||
$notification->setParsedSubject(strtr($subject, $plain))
|
||||
->setRichSubject($subject, $objects)
|
||||
->setParsedMessage(strtr($message, $plain))
|
||||
->setRichMessage($message, $objects);
|
||||
}
|
||||
}
|
||||
@@ -4,18 +4,22 @@ declare(strict_types=1);
|
||||
|
||||
namespace OCA\WorkflowDeckAutomation\Service;
|
||||
|
||||
use OCA\Deck\Db\AssignmentMapper;
|
||||
use OCA\Deck\Db\BoardMapper;
|
||||
use OCA\Deck\Db\Card;
|
||||
use OCA\Deck\Db\CardMapper;
|
||||
use OCA\Deck\Db\StackMapper;
|
||||
use OCA\Deck\Service\BoardService;
|
||||
use OCA\Deck\Service\CardService;
|
||||
use OCA\Deck\Service\PermissionService;
|
||||
use OCA\Deck\Service\StackService;
|
||||
use OCP\App\IAppManager;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\IGroupManager;
|
||||
use OCP\IConfig;
|
||||
use OCP\IUser;
|
||||
use OCP\IUserManager;
|
||||
use OCP\Server;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use ReflectionProperty;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
@@ -32,21 +36,124 @@ class DeckIntegrationService {
|
||||
private const DECK_APP_ID = 'deck';
|
||||
|
||||
/**
|
||||
* Deck's Acl::PERMISSION_TYPE_* values, inlined so this class keeps
|
||||
* working (degrading to "treat it as a user") if Deck ever moves or
|
||||
* renames the constants.
|
||||
* Deck's Acl::PERMISSION_READ, inlined so a moved or renamed constant
|
||||
* cannot fatal this class. It is the key under which
|
||||
* PermissionService::getPermissions() reports read access.
|
||||
*/
|
||||
private const ACL_TYPE_USER = 0;
|
||||
private const ACL_TYPE_GROUP = 1;
|
||||
private const ACL_PERMISSION_READ = 0;
|
||||
|
||||
/**
|
||||
* The property through which Deck carries "the current user" — a plain
|
||||
* string frozen at construction rather than a session lookup. See
|
||||
* beginUserContext().
|
||||
*/
|
||||
private const IDENTITY_PROPERTY = 'userId';
|
||||
|
||||
/**
|
||||
* Further Deck classes holding that same frozen uid. Unlike
|
||||
* PermissionService these do not decide access, only whose name ends up
|
||||
* on an activity entry or whose unread-comment counts are computed, so
|
||||
* failing to pin them is logged and shrugged off.
|
||||
*/
|
||||
private const OPTIONAL_IDENTITY_CLASSES = [
|
||||
CardService::class,
|
||||
BoardService::class,
|
||||
'OCA\Deck\Activity\ActivityManager',
|
||||
];
|
||||
|
||||
/**
|
||||
* @var list<array{ReflectionProperty, object, ?string}>
|
||||
*/
|
||||
private array $identityRestore = [];
|
||||
|
||||
public function __construct(
|
||||
private IAppManager $appManager,
|
||||
private IUserManager $userManager,
|
||||
private IGroupManager $groupManager,
|
||||
private IConfig $config,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Pins Deck to $user until endUserContext() — mandatory before any Deck
|
||||
* call made on behalf of someone who is not the logged-in web user.
|
||||
*
|
||||
* IUserSession::setUser() alone is not enough, and that is not obvious:
|
||||
* Deck's PermissionService — the class behind *every* permission check,
|
||||
* including the ones inside CardService::reorder() — receives the current
|
||||
* user as `private ?string $userId`. The app container fills that from
|
||||
* `ISession::get('user_id')` through a **shared** service, so Pimple
|
||||
* computes it once and caches it for the rest of the process, and
|
||||
* ServerContainer keeps the app container just as long. Whoever was
|
||||
* active when Deck's container first built PermissionService is therefore
|
||||
* the user every later check is evaluated against.
|
||||
*
|
||||
* In the background job that is never the user we are impersonating: it
|
||||
* is `null` whenever a Deck-owned job ran earlier in the same cron pass
|
||||
* (cron.php works through many jobs in one process), and otherwise the
|
||||
* first workflow owner we happened to touch. `null` fails every check,
|
||||
* which is how intact boards started reporting NoPermissionException and
|
||||
* got their workflows switched off.
|
||||
*
|
||||
* Deck exposes no API for this, so the frozen value is swapped directly
|
||||
* and put back in endUserContext(). PermissionService is required: if it
|
||||
* cannot be pinned this throws, and the caller has to skip the user
|
||||
* rather than run them under someone else's permissions.
|
||||
*
|
||||
* @throws DeckUnavailableException
|
||||
*/
|
||||
public function beginUserContext(IUser $user): void {
|
||||
if ($this->identityRestore !== []) {
|
||||
throw new DeckUnavailableException('A Deck user context is already active');
|
||||
}
|
||||
|
||||
$uid = $user->getUID();
|
||||
|
||||
try {
|
||||
$this->pinDeckIdentity($this->resolve(PermissionService::class), $uid);
|
||||
} catch (Throwable $e) {
|
||||
$this->endUserContext();
|
||||
throw new DeckUnavailableException(
|
||||
'Could not run Deck as ' . $uid . ': ' . $e->getMessage(),
|
||||
0,
|
||||
$e,
|
||||
);
|
||||
}
|
||||
|
||||
foreach (self::OPTIONAL_IDENTITY_CLASSES as $class) {
|
||||
try {
|
||||
$this->pinDeckIdentity($this->resolve($class), $uid);
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->debug('Could not pin ' . $class . ' to ' . $uid . ': ' . $e->getMessage(), [
|
||||
'app' => 'workflow_deck_automation',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores what beginUserContext() replaced. Idempotent, so it can be
|
||||
* called unconditionally from a finally block.
|
||||
*/
|
||||
public function endUserContext(): void {
|
||||
foreach (array_reverse($this->identityRestore) as [$property, $service, $previous]) {
|
||||
try {
|
||||
$property->setValue($service, $previous);
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->warning('Could not restore Deck identity on ' . $service::class . ': ' . $e->getMessage(), [
|
||||
'app' => 'workflow_deck_automation',
|
||||
]);
|
||||
}
|
||||
}
|
||||
$this->identityRestore = [];
|
||||
}
|
||||
|
||||
private function pinDeckIdentity(object $service, string $uid): void {
|
||||
$property = new ReflectionProperty($service, self::IDENTITY_PROPERTY);
|
||||
$previous = $property->isInitialized($service) ? $property->getValue($service) : null;
|
||||
$property->setValue($service, $uid);
|
||||
$this->identityRestore[] = [$property, $service, is_string($previous) ? $previous : null];
|
||||
}
|
||||
|
||||
public function assertDeckAvailable(IUser $user): void {
|
||||
if (!$this->appManager->isEnabledForUser(self::DECK_APP_ID, $user)) {
|
||||
throw new DeckUnavailableException('Deck is not enabled for user ' . $user->getUID());
|
||||
@@ -57,56 +164,44 @@ class DeckIntegrationService {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{id: int, title: string}>
|
||||
* The boards `$userId` owns — the only boards workflows may live on, and
|
||||
* the boards whose workflows that user sees and manages.
|
||||
*
|
||||
* Only ever call this from a *request*: Deck injects the current user id
|
||||
* into BoardService as a plain string frozen at construction time, so in
|
||||
* the background job (one process, many users, a container-cached
|
||||
* BoardService) this would answer for the wrong user — or for none at
|
||||
* all. The job uses findStackIds() instead, which goes through Deck's
|
||||
* PermissionService and therefore reads the live session.
|
||||
* One query: BoardMapper::findAllByOwner() is `WHERE owner = :uid`, so
|
||||
* shared boards never come back, and as a plain QBMapper it has none of
|
||||
* the frozen-uid state of BoardService::getUserBoards(). It does return
|
||||
* archived and trashed boards; trashed ones are dropped here, archived
|
||||
* ones are kept but flagged — their workflows still exist and must stay
|
||||
* visible, they just aren't offered as a new target.
|
||||
*
|
||||
* Throws instead of degrading to `[]`: an empty list would read as "you
|
||||
* own no boards" and hide every workflow the user has.
|
||||
*
|
||||
* @return array<int, array{id: int, title: string, archived: bool}>
|
||||
* @throws DeckUnavailableException
|
||||
*/
|
||||
public function listBoardsForCurrentUser(): array {
|
||||
$boards = $this->call(function () {
|
||||
$boardService = $this->resolve(BoardService::class);
|
||||
try {
|
||||
// getUserBoards(?int $since, bool $includeArchived, …): with
|
||||
// the default $includeArchived = true, Deck skips the
|
||||
// `archived = false AND deleted_at = 0` conditions entirely,
|
||||
// so archived *and* trashed boards come back. Ask for the
|
||||
// filtered query instead of sorting them out afterwards.
|
||||
return $boardService->getUserBoards(null, false);
|
||||
} catch (Throwable $e) {
|
||||
// Older/newer Deck with a different signature: fall back to
|
||||
// the unfiltered call, isBoardHidden() below still filters.
|
||||
return $boardService->getUserBoards();
|
||||
}
|
||||
}, []);
|
||||
public function listOwnedBoards(string $userId): array {
|
||||
$boards = $this->call(
|
||||
fn () => $this->resolve(BoardMapper::class)->findAllByOwner($userId),
|
||||
[],
|
||||
true,
|
||||
);
|
||||
|
||||
// Deck merges own/group/circle boards, so the same board can come
|
||||
// back more than once; and boards in the trash or archived ones are
|
||||
// no useful automation target. Keyed by id => deduplicated.
|
||||
$result = [];
|
||||
foreach ($boards as $board) {
|
||||
$id = (int)$board->getId();
|
||||
if (isset($result[$id]) || $this->isBoardHidden($board)) {
|
||||
if (method_exists($board, 'getDeletedAt') && (int)$board->getDeletedAt() > 0) {
|
||||
continue;
|
||||
}
|
||||
$id = (int)$board->getId();
|
||||
$title = $board->getTitle();
|
||||
$result[$id] = [
|
||||
$result[] = [
|
||||
'id' => $id,
|
||||
'title' => (is_string($title) && $title !== '') ? $title : ('#' . $id),
|
||||
'archived' => method_exists($board, 'getArchived') && (bool)$board->getArchived(),
|
||||
];
|
||||
}
|
||||
|
||||
return array_values($result);
|
||||
}
|
||||
|
||||
private function isBoardHidden(mixed $board): bool {
|
||||
if (method_exists($board, 'getDeletedAt') && (int)$board->getDeletedAt() > 0) {
|
||||
return true;
|
||||
}
|
||||
return method_exists($board, 'getArchived') && $board->getArchived() === true;
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,23 +222,46 @@ class DeckIntegrationService {
|
||||
* Ids of the board's stacks, for deciding whether a stored workflow still
|
||||
* points at anything real.
|
||||
*
|
||||
* Returns `null` when the board itself is gone or not readable for the
|
||||
* current user — `StackService::findAll()` runs a Deck permission check
|
||||
* first, and that one reads the *live* session, which is what makes this
|
||||
* usable from the impersonating background job. Any other failure throws,
|
||||
* Returns `null` only on positive knowledge that the board is gone, in the
|
||||
* trash, or no longer readable for `$userId`. Every other failure throws,
|
||||
* because "Deck is broken right now" must never be mistaken for "the user
|
||||
* deleted this board".
|
||||
* deleted this board" — the caller reacts by switching the workflow off
|
||||
* and mailing its owner.
|
||||
*
|
||||
* Deliberately assembled from pieces that cannot answer for the wrong
|
||||
* user. BoardMapper and StackMapper are plain QBMappers with no user
|
||||
* state (StackMapper::findAll() filters `deleted_at = 0` itself), and
|
||||
* getPermissions() is handed the uid explicitly instead of falling back
|
||||
* to PermissionService's construction-time `$userId`. Leaning on that
|
||||
* frozen value — which is exactly what `StackService::findAll()` does
|
||||
* internally — is what disabled workflows whose board was perfectly
|
||||
* intact, so this must not be "simplified" back to one service call even
|
||||
* once beginUserContext() has pinned the identity.
|
||||
*
|
||||
* @return int[]|null
|
||||
* @throws DeckUnavailableException
|
||||
*/
|
||||
public function findStackIds(int $boardId): ?array {
|
||||
public function findStackIds(int $boardId, string $userId): ?array {
|
||||
try {
|
||||
$stacks = $this->resolve(StackService::class)->findAll($boardId);
|
||||
$board = $this->resolve(BoardMapper::class)->find($boardId);
|
||||
if (method_exists($board, 'getDeletedAt') && (int)$board->getDeletedAt() > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$permissions = $this->resolve(PermissionService::class)->getPermissions($boardId, $userId);
|
||||
if (!array_key_exists(self::ACL_PERMISSION_READ, $permissions)) {
|
||||
// Unknown shape: say nothing rather than something wrong.
|
||||
throw new DeckUnavailableException('Deck reported no read permission for board ' . $boardId);
|
||||
}
|
||||
if ($permissions[self::ACL_PERMISSION_READ] !== true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$stacks = $this->resolve(StackMapper::class)->findAll($boardId);
|
||||
} catch (DeckUnavailableException $e) {
|
||||
throw $e;
|
||||
} catch (Throwable $e) {
|
||||
if ($this->isMissingOrForbidden($e)) {
|
||||
if ($this->isMissing($e)) {
|
||||
return null;
|
||||
}
|
||||
$this->logger->error('Could not list stacks of board ' . $boardId . ': ' . $e->getMessage(), [
|
||||
@@ -157,100 +275,165 @@ class DeckIntegrationService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Deck's own exception classes are referenced by name: `is_a()` with a
|
||||
* string simply returns false when the class does not exist, so a missing
|
||||
* or renamed Deck degrades to "unknown error" instead of fataling.
|
||||
* The uid of the board's current owner — the user a workflow on that
|
||||
* board belongs to. Workflows follow the board: when Deck transfers its
|
||||
* ownership, they move to the new owner along with it.
|
||||
*
|
||||
* Read through BoardMapper, a plain QBMapper without user state, so it
|
||||
* answers the same in a request and in cron. Trashed and archived boards
|
||||
* still report their owner. `null` only if the board row is gone; every
|
||||
* other failure throws, like findStackIds().
|
||||
*
|
||||
* @throws DeckUnavailableException
|
||||
*/
|
||||
private function isMissingOrForbidden(Throwable $e): bool {
|
||||
if ($e instanceof DoesNotExistException) {
|
||||
return true;
|
||||
public function findBoardOwner(int $boardId): ?string {
|
||||
try {
|
||||
$board = $this->resolve(BoardMapper::class)->find($boardId);
|
||||
} catch (DeckUnavailableException $e) {
|
||||
throw $e;
|
||||
} catch (Throwable $e) {
|
||||
if ($this->isMissing($e)) {
|
||||
return null;
|
||||
}
|
||||
foreach (['OCA\Deck\NoPermissionException', 'OCA\Deck\NotFoundException'] as $class) {
|
||||
if (is_a($e, $class)) {
|
||||
return true;
|
||||
throw new DeckUnavailableException('Could not read owner of board ' . $boardId . ': ' . $e->getMessage(), 0, $e);
|
||||
}
|
||||
|
||||
$owner = $this->unwrapUid($board->getOwner());
|
||||
if ($owner === null) {
|
||||
throw new DeckUnavailableException('Board ' . $boardId . ' has no readable owner');
|
||||
}
|
||||
return false;
|
||||
return $owner;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{id: int, title: string, color: string|null}>
|
||||
* Who should hear about each of the given (moved) cards, according to
|
||||
* every board member's own Deck notification setting for this board —
|
||||
* the "Notifications: none / assigned cards / all cards" menu, stored as
|
||||
* the user preference `deck` / `board:<id>:notify-due`. The decision
|
||||
* itself is NotificationPolicy::shouldNotify(), a copy of Deck's rules.
|
||||
*
|
||||
* Like findStackIds(), assembled only from pieces that take the users
|
||||
* explicitly and never consult Deck's frozen `$userId`:
|
||||
* PermissionService::findUsers() reads the owner and ACL through mappers
|
||||
* and expands groups and circles itself; AssignmentMapper is a plain
|
||||
* mapper that also resolves group/circle assignments; the preference is
|
||||
* read straight from IConfig.
|
||||
*
|
||||
* Throws on any failure, so the caller can log and skip the mail instead
|
||||
* of mailing a half-resolved or wrong set of people.
|
||||
*
|
||||
* @param int[] $cardIds
|
||||
* @return array<int, list<string>> card id => uids to notify
|
||||
* @throws DeckUnavailableException
|
||||
*/
|
||||
public function listLabels(int $boardId): array {
|
||||
$labels = $this->call(function () use ($boardId) {
|
||||
$board = $this->resolve(BoardService::class)->find($boardId, true);
|
||||
return $board->getLabels() ?? [];
|
||||
}, []);
|
||||
public function findNotificationRecipients(int $boardId, array $cardIds): array {
|
||||
try {
|
||||
$board = $this->resolve(BoardMapper::class)->find($boardId, false, true);
|
||||
$owner = $this->unwrapUid($board->getOwner());
|
||||
// Deck notifies the owner of a board nobody else can see about
|
||||
// every card: "assigned" means nothing there.
|
||||
$unshared = count($board->getAcl() ?? []) === 0;
|
||||
|
||||
return array_map(
|
||||
static fn ($label) => ['id' => $label->getId(), 'title' => $label->getTitle(), 'color' => $label->getColor()],
|
||||
$labels,
|
||||
// `true` bypasses PermissionService's per-process cache, which
|
||||
// would otherwise outlive ACL changes for the whole cron pass.
|
||||
$participants = $this->resolve(PermissionService::class)->findUsers($boardId, true);
|
||||
$assignments = $this->resolve(AssignmentMapper::class);
|
||||
|
||||
$settings = [];
|
||||
foreach ($participants as $uid => $participant) {
|
||||
// Users of other instances (FederatedUser) have no local
|
||||
// account and no preference to read.
|
||||
if (!is_a($participant, 'OCA\Deck\Db\User')) {
|
||||
continue;
|
||||
}
|
||||
// Numeric uids come back as integer array keys.
|
||||
$uid = (string)$uid;
|
||||
$settings[$uid] = (string)$this->config->getUserValue(
|
||||
$uid,
|
||||
self::DECK_APP_ID,
|
||||
'board:' . $boardId . ':notify-due',
|
||||
NotificationPolicy::DECK_DEFAULT,
|
||||
);
|
||||
}
|
||||
|
||||
$recipients = [];
|
||||
foreach ($cardIds as $cardId) {
|
||||
$recipients[$cardId] = [];
|
||||
foreach ($settings as $uid => $setting) {
|
||||
$uid = (string)$uid;
|
||||
$notify = NotificationPolicy::shouldNotify(
|
||||
$setting,
|
||||
$unshared && $uid === $owner,
|
||||
static fn (): bool => (bool)$assignments->isUserAssigned($cardId, $uid),
|
||||
);
|
||||
if ($notify) {
|
||||
$recipients[$cardId][] = $uid;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (DeckUnavailableException $e) {
|
||||
throw $e;
|
||||
} catch (Throwable $e) {
|
||||
throw new DeckUnavailableException('Could not resolve notification recipients for board ' . $boardId . ': ' . $e->getMessage(), 0, $e);
|
||||
}
|
||||
|
||||
return $recipients;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everyone who can hold a card on this board: the board owner (who is
|
||||
* *not* part of the ACL — a private board has an empty ACL) plus every
|
||||
* ACL entry, with group shares expanded to their members.
|
||||
* Deck's own exception classes are referenced by name: `is_a()` with a
|
||||
* string simply returns false when the class does not exist, so a missing
|
||||
* or renamed Deck degrades to "unknown error" instead of fataling.
|
||||
*
|
||||
* @return array<int, array{uid: string, displayName: string}>
|
||||
* `OCA\Deck\NoPermissionException` is deliberately *not* treated as "the
|
||||
* board is gone" here. It used to be, and that is what switched intact
|
||||
* workflows off: Deck raises it for any check that fails, including every
|
||||
* check evaluated against the wrong frozen uid — which says nothing at
|
||||
* all about whether the board still exists.
|
||||
*/
|
||||
public function listParticipants(int $boardId): array {
|
||||
$uids = $this->call(function () use ($boardId) {
|
||||
$board = $this->resolve(BoardService::class)->find($boardId, true);
|
||||
|
||||
$uids = [];
|
||||
$owner = $this->unwrapUid($board->getOwner());
|
||||
if ($owner !== null) {
|
||||
$uids[] = $owner;
|
||||
private function isMissing(Throwable $e): bool {
|
||||
if ($e instanceof DoesNotExistException) {
|
||||
return true;
|
||||
}
|
||||
return is_a($e, 'OCA\Deck\NotFoundException');
|
||||
}
|
||||
|
||||
foreach ($board->getAcl() ?? [] as $entry) {
|
||||
$principal = $this->extractParticipantUid($entry);
|
||||
if ($principal === null) {
|
||||
continue;
|
||||
}
|
||||
$type = method_exists($entry, 'getType') ? (int)$entry->getType() : self::ACL_TYPE_USER;
|
||||
if ($type === self::ACL_TYPE_GROUP) {
|
||||
array_push($uids, ...$this->groupMemberUids($principal));
|
||||
} elseif ($type === self::ACL_TYPE_USER) {
|
||||
$uids[] = $principal;
|
||||
}
|
||||
// Circles and federated shares are skipped: their members
|
||||
// cannot be resolved to plain uids here.
|
||||
/**
|
||||
* Titles of a workflow's board and stacks, for the notification mail.
|
||||
*
|
||||
* Best effort by design: this only decorates a mail that is being sent
|
||||
* because a card *was* already moved, so anything unreadable degrades to
|
||||
* `#<id>` rather than costing the user their notification.
|
||||
*
|
||||
* @return array{board: string, sourceStack: string, targetStack: string}
|
||||
*/
|
||||
public function describeTargets(int $boardId, int $sourceStackId, int $targetStackId): array {
|
||||
$titles = $this->call(function () use ($boardId) {
|
||||
$stackTitles = [];
|
||||
foreach ($this->resolve(StackMapper::class)->findAll($boardId) as $stack) {
|
||||
$stackTitles[(int)$stack->getId()] = (string)$stack->getTitle();
|
||||
}
|
||||
|
||||
return $uids;
|
||||
}, []);
|
||||
return [
|
||||
'board' => (string)$this->resolve(BoardMapper::class)->find($boardId)->getTitle(),
|
||||
'stacks' => $stackTitles,
|
||||
];
|
||||
}, ['board' => '', 'stacks' => []]);
|
||||
|
||||
$participants = [];
|
||||
foreach ($uids as $uid) {
|
||||
if (isset($participants[$uid])) {
|
||||
continue;
|
||||
}
|
||||
$participants[$uid] = [
|
||||
'uid' => $uid,
|
||||
'displayName' => $this->userManager->get($uid)?->getDisplayName() ?? $uid,
|
||||
return [
|
||||
'board' => self::titleOr($titles['board'], $boardId),
|
||||
'sourceStack' => self::titleOr($titles['stacks'][$sourceStackId] ?? '', $sourceStackId),
|
||||
'targetStack' => self::titleOr($titles['stacks'][$targetStackId] ?? '', $targetStackId),
|
||||
];
|
||||
}
|
||||
|
||||
return array_values($participants);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
private function groupMemberUids(string $groupId): array {
|
||||
$group = $this->groupManager->get($groupId);
|
||||
if ($group === null) {
|
||||
return [];
|
||||
}
|
||||
return array_map(static fn (IUser $user) => $user->getUID(), $group->getUsers());
|
||||
private static function titleOr(string $title, int $id): string {
|
||||
return $title !== '' ? $title : ('#' . $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cards in the given stack that are neither archived, deleted nor marked
|
||||
* done, enriched so getAssignedUsers()/getLabels() are populated.
|
||||
* done.
|
||||
*
|
||||
* The `deletedAt` check is not redundant: Deck's
|
||||
* `CardMapper::findAllByStack()` filters on `stack_id` and
|
||||
@@ -262,11 +445,12 @@ class DeckIntegrationService {
|
||||
*/
|
||||
public function getActiveCardsInStack(int $stackId): array {
|
||||
return $this->call(function () use ($stackId) {
|
||||
$cardMapper = $this->resolve(CardMapper::class);
|
||||
$cardService = $this->resolve(CardService::class);
|
||||
|
||||
$cards = $cardMapper->findAllByStack($stackId);
|
||||
$cards = $cardService->enrichCards($cards);
|
||||
// Deliberately not enriched: CardService::enrichCards() only adds
|
||||
// labels, assignees and comment counts, and runs
|
||||
// BoardService::find(), a permission check against Deck's frozen
|
||||
// uid (see beginUserContext()). Assignments, where needed for
|
||||
// mails, come from findNotificationRecipients() instead.
|
||||
$cards = $this->resolve(CardMapper::class)->findAllByStack($stackId);
|
||||
|
||||
return array_values(array_filter($cards, static function (Card $card) {
|
||||
if (method_exists($card, 'getDeletedAt') && (int)$card->getDeletedAt() > 0) {
|
||||
@@ -277,35 +461,6 @@ class DeckIntegrationService {
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getCardAssignedUserIds(Card $card): array {
|
||||
$assigned = $card->getAssignedUsers() ?? [];
|
||||
$uids = [];
|
||||
foreach ($assigned as $entry) {
|
||||
$uid = $this->extractParticipantUid($entry);
|
||||
if ($uid !== null) {
|
||||
$uids[] = $uid;
|
||||
}
|
||||
}
|
||||
return $uids;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int[]
|
||||
*/
|
||||
public function getCardLabelIds(Card $card): array {
|
||||
$labels = $card->getLabels() ?? [];
|
||||
$ids = [];
|
||||
foreach ($labels as $label) {
|
||||
if (method_exists($label, 'getId')) {
|
||||
$ids[] = (int)$label->getId();
|
||||
}
|
||||
}
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a card to another stack using Deck's own reorder logic
|
||||
* (the exact same code path behind Deck's "move card" action).
|
||||
@@ -352,31 +507,6 @@ class DeckIntegrationService {
|
||||
return Server::get($class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deck's assignment/ACL entries are internal, undocumented value
|
||||
* objects whose exact accessor shape has changed across versions, so
|
||||
* we probe the common accessor names defensively instead of relying
|
||||
* on one fixed method signature.
|
||||
*/
|
||||
private function extractParticipantUid(mixed $entry): ?string {
|
||||
$uid = $this->unwrapUid($entry);
|
||||
if ($uid !== null) {
|
||||
return $uid;
|
||||
}
|
||||
if (!is_object($entry)) {
|
||||
return null;
|
||||
}
|
||||
foreach (['getParticipant', 'getUid', 'getParticipantUid', 'getUserId'] as $method) {
|
||||
if (method_exists($entry, $method)) {
|
||||
$uid = $this->unwrapUid($entry->$method());
|
||||
if ($uid !== null) {
|
||||
return $uid;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns whatever Deck hands out for a "participant"/"owner" field into
|
||||
* a plain uid.
|
||||
|
||||
@@ -6,7 +6,6 @@ namespace OCA\WorkflowDeckAutomation\Service;
|
||||
|
||||
use OCA\Deck\Db\Card;
|
||||
use OCA\WorkflowDeckAutomation\Db\Workflow;
|
||||
use OCP\IL10N;
|
||||
use OCP\IURLGenerator;
|
||||
use OCP\IUser;
|
||||
use OCP\Mail\IEMailTemplate;
|
||||
@@ -15,69 +14,134 @@ use OCP\Util;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Throwable;
|
||||
|
||||
class NotificationMailer {
|
||||
/**
|
||||
* Descriptions are unbounded in Deck; keep the mail readable and well
|
||||
* clear of any MTA size limits.
|
||||
* The app is used internally only and speaks German, like its settings
|
||||
* page: texts are written in German directly instead of going through
|
||||
* IL10N with English source strings and a translation file.
|
||||
*/
|
||||
private const MAX_DESCRIPTION_LENGTH = 2000;
|
||||
class NotificationMailer {
|
||||
private const APP_ID = 'workflow_deck_automation';
|
||||
|
||||
private const SENDER_NAME = 'Deck Workflow-Automatisierung';
|
||||
|
||||
/**
|
||||
* Language of Nextcloud's own footer text ("automatically sent email,
|
||||
* please do not reply"), which would otherwise follow the instance
|
||||
* default — in cron there is no request to take a language from.
|
||||
*/
|
||||
private const FOOTER_LANGUAGE = 'de';
|
||||
|
||||
public function __construct(
|
||||
private IMailer $mailer,
|
||||
private IURLGenerator $urlGenerator,
|
||||
private IL10N $l10n,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
public function sendCardMovedNotification(IUser $user, Workflow $workflow, Card $card): void {
|
||||
$email = $user->getEMailAddress();
|
||||
/**
|
||||
* One mail listing every card moved for $recipient in this run — they
|
||||
* may come from several workflows and boards.
|
||||
*
|
||||
* @param non-empty-list<array{workflow: Workflow, card: Card, targets: array{board: string, sourceStack: string, targetStack: string}}> $entries
|
||||
* targets are the titles resolved by DeckIntegrationService::describeTargets()
|
||||
*/
|
||||
public function sendCardsMovedNotification(IUser $recipient, array $entries): void {
|
||||
$email = $recipient->getEMailAddress();
|
||||
if ($email === null || $email === '') {
|
||||
$this->logger->info('Skipping notification for workflow ' . $workflow->getId() . ': user ' . $user->getUID() . ' has no email address', [
|
||||
'app' => 'workflow_deck_automation',
|
||||
$this->logger->info('Skipping moved-cards mail: user ' . $recipient->getUID() . ' has no email address', [
|
||||
'app' => self::APP_ID,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$cardLink = $this->urlGenerator->getAbsoluteURL(
|
||||
'/apps/deck/board/' . $workflow->getBoardId() . '/card/' . $card->getId(),
|
||||
);
|
||||
$count = count($entries);
|
||||
|
||||
$template = $this->mailer->createEMailTemplate('workflow_deck_automation.CardMoved', [
|
||||
'cardTitle' => $card->getTitle(),
|
||||
'workflowTitle' => $workflow->getTitle(),
|
||||
try {
|
||||
$template = $this->mailer->createEMailTemplate(self::APP_ID . '.CardsMoved', [
|
||||
'count' => $count,
|
||||
'cardTitles' => array_map(static fn (array $entry) => $entry['card']->getTitle(), $entries),
|
||||
]);
|
||||
$template->setSubject($this->l10n->t('Deck card moved: %s', [$card->getTitle()]));
|
||||
$template->setSubject($count === 1
|
||||
? sprintf('Deck-Karte verschoben: %s', $entries[0]['card']->getTitle())
|
||||
: sprintf('%d Deck-Karten verschoben', $count));
|
||||
$template->addHeader();
|
||||
$template->addHeading($this->l10n->t('A card was moved automatically'), false);
|
||||
$template->addBodyText($this->l10n->t(
|
||||
'The card "%1$s" was moved because it is overdue (workflow "%2$s").',
|
||||
[$card->getTitle(), $workflow->getTitle()],
|
||||
));
|
||||
$this->addCardDescription($template, $card);
|
||||
$template->addBodyButton($this->l10n->t('Open card'), $cardLink);
|
||||
$template->addFooter();
|
||||
$template->addHeading($count === 1
|
||||
? 'Eine Karte wurde automatisch verschoben'
|
||||
: sprintf('%d Karten wurden automatisch verschoben', $count), false);
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
$this->addCard($template, $entry['workflow'], $entry['card'], $entry['targets']);
|
||||
}
|
||||
|
||||
$template->addFooter('', self::FOOTER_LANGUAGE);
|
||||
|
||||
$message = $this->mailer->createMessage();
|
||||
$message->setTo([$email => $user->getDisplayName()]);
|
||||
$message->setFrom([Util::getDefaultEmailAddress('noreply') => $this->l10n->t('Deck Workflow Automation')]);
|
||||
$message->setTo([$email => $recipient->getDisplayName()]);
|
||||
$message->setFrom([Util::getDefaultEmailAddress('noreply') => self::SENDER_NAME]);
|
||||
$message->useTemplate($template);
|
||||
|
||||
$failedRecipients = $this->mailer->send($message);
|
||||
if (!empty($failedRecipients)) {
|
||||
$this->logger->error('Notification mail for card ' . $card->getId() . ' failed for: ' . implode(', ', $failedRecipients), [
|
||||
'app' => 'workflow_deck_automation',
|
||||
$this->logger->error('Moved-cards mail failed for: ' . implode(', ', $failedRecipients), [
|
||||
'app' => self::APP_ID,
|
||||
]);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error('Could not send notification mail for card ' . $card->getId() . ': ' . $e->getMessage(), [
|
||||
'app' => 'workflow_deck_automation',
|
||||
$this->logger->error('Could not send moved-cards mail to ' . $recipient->getUID() . ': ' . $e->getMessage(), [
|
||||
'app' => self::APP_ID,
|
||||
'exception' => $e,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{board: string, sourceStack: string, targetStack: string} $targets
|
||||
*/
|
||||
private function addCard(IEMailTemplate $template, Workflow $workflow, Card $card, array $targets): void {
|
||||
// The card description and the workflow's name are deliberately left
|
||||
// out — the mail only says what moved where. The card title itself is
|
||||
// the link to the card.
|
||||
$sentence = '„%1$s“ (Board „%2$s“) ist überfällig und wurde von „%3$s“ nach „%4$s“ verschoben.';
|
||||
$url = $this->urlGenerator->getAbsoluteURL('/apps/deck/board/' . $workflow->getBoardId() . '/card/' . $card->getId());
|
||||
|
||||
// With both parts passed, addBodyText() escapes nothing: every title
|
||||
// is user input and has to be escaped here for the HTML part.
|
||||
$html = sprintf(
|
||||
$sentence,
|
||||
'<a href="' . self::escape($url) . '">' . self::escape($card->getTitle()) . '</a>',
|
||||
self::escape($targets['board']),
|
||||
self::escape($targets['sourceStack']),
|
||||
self::escape($targets['targetStack']),
|
||||
);
|
||||
$plain = sprintf(
|
||||
$sentence,
|
||||
$card->getTitle(),
|
||||
$targets['board'],
|
||||
$targets['sourceStack'],
|
||||
$targets['targetStack'],
|
||||
) . PHP_EOL . $url;
|
||||
|
||||
$template->addBodyText($html, $plain);
|
||||
}
|
||||
|
||||
private static function escape(string $text): string {
|
||||
return htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Completes "… wurde ausgeschaltet, weil %s." — shared with
|
||||
* Notification\Notifier so mail and notification give the same reason.
|
||||
*
|
||||
* @param WorkflowRunner::TARGET_* $brokenTarget
|
||||
*/
|
||||
public static function describeBrokenTarget(string $brokenTarget): string {
|
||||
return match ($brokenTarget) {
|
||||
WorkflowRunner::TARGET_SOURCE_STACK => 'sein Quell-Stapel nicht mehr existiert',
|
||||
WorkflowRunner::TARGET_TARGET_STACK => 'sein Ziel-Stapel nicht mehr existiert',
|
||||
default => 'sein Board nicht mehr existiert oder für dich nicht mehr verfügbar ist',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One-off notice that a workflow was switched off because the board or
|
||||
* stack it points at is gone.
|
||||
@@ -88,81 +152,49 @@ class NotificationMailer {
|
||||
$email = $user->getEMailAddress();
|
||||
if ($email === null || $email === '') {
|
||||
$this->logger->info('Cannot notify about disabled workflow ' . $workflow->getId() . ': user ' . $user->getUID() . ' has no email address', [
|
||||
'app' => 'workflow_deck_automation',
|
||||
'app' => self::APP_ID,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$reason = match ($brokenTarget) {
|
||||
WorkflowRunner::TARGET_SOURCE_STACK => $this->l10n->t('its source stack no longer exists'),
|
||||
WorkflowRunner::TARGET_TARGET_STACK => $this->l10n->t('its target stack no longer exists'),
|
||||
default => $this->l10n->t('its board no longer exists or is no longer available to you'),
|
||||
};
|
||||
$reason = self::describeBrokenTarget($brokenTarget);
|
||||
|
||||
try {
|
||||
$settingsLink = $this->urlGenerator->getAbsoluteURL('/settings/user/workflow_deck_automation');
|
||||
$settingsLink = $this->urlGenerator->getAbsoluteURL('/settings/user/' . self::APP_ID);
|
||||
|
||||
$template = $this->mailer->createEMailTemplate('workflow_deck_automation.WorkflowDisabled', [
|
||||
$template = $this->mailer->createEMailTemplate(self::APP_ID . '.WorkflowDisabled', [
|
||||
'workflowTitle' => $workflow->getTitle(),
|
||||
]);
|
||||
$template->setSubject($this->l10n->t('Deck workflow deactivated: %s', [$workflow->getTitle()]));
|
||||
$template->setSubject(sprintf('Deck-Workflow deaktiviert: %s', $workflow->getTitle()));
|
||||
$template->addHeader();
|
||||
$template->addHeading($this->l10n->t('A workflow was deactivated'), false);
|
||||
$template->addBodyText($this->l10n->t(
|
||||
'The workflow "%1$s" was switched off automatically because %2$s. No cards are being moved by it any more.',
|
||||
[$workflow->getTitle(), $reason],
|
||||
$template->addHeading('Ein Workflow wurde deaktiviert', false);
|
||||
$template->addBodyText(sprintf(
|
||||
'Der Workflow „%1$s“ wurde automatisch ausgeschaltet, weil %2$s. Er verschiebt keine Karten mehr.',
|
||||
$workflow->getTitle(),
|
||||
$reason,
|
||||
));
|
||||
$template->addBodyText($this->l10n->t(
|
||||
'Delete the workflow or point it at an existing board and stack to reactivate it. You will not be reminded about this workflow again.',
|
||||
));
|
||||
$template->addBodyButton($this->l10n->t('Open settings'), $settingsLink);
|
||||
$template->addFooter();
|
||||
$template->addBodyText(
|
||||
'Lösche den Workflow oder stelle ein vorhandenes Board und vorhandene Stapel ein, um ihn wieder zu aktivieren. An diesen Workflow wirst du nicht noch einmal erinnert.',
|
||||
);
|
||||
$template->addBodyButton('Einstellungen öffnen', $settingsLink);
|
||||
$template->addFooter('', self::FOOTER_LANGUAGE);
|
||||
|
||||
$message = $this->mailer->createMessage();
|
||||
$message->setTo([$email => $user->getDisplayName()]);
|
||||
$message->setFrom([Util::getDefaultEmailAddress('noreply') => $this->l10n->t('Deck Workflow Automation')]);
|
||||
$message->setFrom([Util::getDefaultEmailAddress('noreply') => self::SENDER_NAME]);
|
||||
$message->useTemplate($template);
|
||||
|
||||
$failedRecipients = $this->mailer->send($message);
|
||||
if (!empty($failedRecipients)) {
|
||||
$this->logger->error('Deactivation mail for workflow ' . $workflow->getId() . ' failed for: ' . implode(', ', $failedRecipients), [
|
||||
'app' => 'workflow_deck_automation',
|
||||
'app' => self::APP_ID,
|
||||
]);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error('Could not send deactivation mail for workflow ' . $workflow->getId() . ': ' . $e->getMessage(), [
|
||||
'app' => 'workflow_deck_automation',
|
||||
'app' => self::APP_ID,
|
||||
'exception' => $e,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends the card's description, if it has one.
|
||||
*
|
||||
* Deck stores the description as Markdown. It is sent as-is rather than
|
||||
* rendered: pulling a Markdown parser in just for the mail would be a
|
||||
* lot of machinery, and unrendered Markdown still reads fine.
|
||||
*/
|
||||
private function addCardDescription(IEMailTemplate $template, Card $card): void {
|
||||
$description = trim((string)$card->getDescription());
|
||||
if ($description === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mb_strlen($description) > self::MAX_DESCRIPTION_LENGTH) {
|
||||
$description = mb_substr($description, 0, self::MAX_DESCRIPTION_LENGTH) . ' […]';
|
||||
}
|
||||
|
||||
$template->addBodyText($this->l10n->t('Card content:'));
|
||||
|
||||
// addBodyText() only runs htmlspecialchars() when it has to build the
|
||||
// plain-text part itself. Passing both parts means escaping is on us
|
||||
// -- and it has to be, because a card description is user input that
|
||||
// would otherwise land unescaped in the HTML mail.
|
||||
$template->addBodyText(
|
||||
nl2br(htmlspecialchars($description, ENT_QUOTES, 'UTF-8'), false),
|
||||
$description,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\WorkflowDeckAutomation\Service;
|
||||
|
||||
/**
|
||||
* Who hears about a moved card, and how — pure logic, no Nextcloud or Deck
|
||||
* dependencies, so it is unit-testable like WorkflowRunner::isOverdue().
|
||||
*/
|
||||
class NotificationPolicy {
|
||||
/** No mail at all. */
|
||||
public const MODE_OFF = 'off';
|
||||
/** Only the workflow's owner (the board owner), regardless of Deck settings. */
|
||||
public const MODE_OWNER = 'owner';
|
||||
/** Everyone with access to the board, filtered by their own Deck setting. */
|
||||
public const MODE_BOARD = 'board';
|
||||
|
||||
public const MODES = [self::MODE_OFF, self::MODE_OWNER, self::MODE_BOARD];
|
||||
|
||||
/** One mail per recipient and run. */
|
||||
public const CHANNEL_MAIL = 'mail';
|
||||
/** One Nextcloud notification (bell, push to the mobile apps) per card. */
|
||||
public const CHANNEL_NOTIFICATION = 'notification';
|
||||
public const CHANNEL_BOTH = 'both';
|
||||
|
||||
public const CHANNELS = [self::CHANNEL_MAIL, self::CHANNEL_NOTIFICATION, self::CHANNEL_BOTH];
|
||||
|
||||
/**
|
||||
* Values of Deck's per-user, per-board preference
|
||||
* `deck` / `board:<boardId>:notify-due` (Deck's
|
||||
* ConfigService::SETTING_BOARD_NOTIFICATION_DUE_*), inlined so a renamed
|
||||
* constant cannot fatal this app. It is the board's "Notifications" menu
|
||||
* in Deck — the due-date reminder setting, which is exactly what fits a
|
||||
* card being moved because it fell due.
|
||||
*/
|
||||
public const DECK_OFF = 'off';
|
||||
public const DECK_ASSIGNED = 'assigned';
|
||||
public const DECK_ALL = 'all';
|
||||
/** What Deck assumes for a user who never touched the setting. */
|
||||
public const DECK_DEFAULT = self::DECK_ASSIGNED;
|
||||
|
||||
public static function isValidMode(string $mode): bool {
|
||||
return in_array($mode, self::MODES, true);
|
||||
}
|
||||
|
||||
public static function isValidChannel(string $channel): bool {
|
||||
return in_array($channel, self::CHANNELS, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* An unknown channel counts as `mail`, the column default — mail was the
|
||||
* only channel before the column existed.
|
||||
*/
|
||||
public static function sendsMail(string $channel): bool {
|
||||
return $channel !== self::CHANNEL_NOTIFICATION;
|
||||
}
|
||||
|
||||
public static function sendsNotification(string $channel): bool {
|
||||
return $channel === self::CHANNEL_NOTIFICATION || $channel === self::CHANNEL_BOTH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors Deck's own decision in NotificationHelper::sendCardDuedate():
|
||||
*
|
||||
* - `off` never notifies — not even the owner of an unshared board;
|
||||
* - `all` always notifies;
|
||||
* - the owner of an unshared board is notified for every card, since on a
|
||||
* board nobody else can see "assigned" would otherwise mean nothing;
|
||||
* - `assigned` notifies users assigned to the card, directly or through a
|
||||
* group or circle.
|
||||
*
|
||||
* An unknown value is treated as Deck's default. Deck rejects anything
|
||||
* else when storing the setting, so that only happens if the preference
|
||||
* was edited by hand.
|
||||
*
|
||||
* @param bool|callable(): bool $isAssigned a callable is only invoked when
|
||||
* the answer actually depends on it — it costs a query per user
|
||||
*/
|
||||
public static function shouldNotify(string $deckSetting, bool $isOwnerOfUnsharedBoard, bool|callable $isAssigned): bool {
|
||||
if (!in_array($deckSetting, [self::DECK_OFF, self::DECK_ASSIGNED, self::DECK_ALL], true)) {
|
||||
$deckSetting = self::DECK_DEFAULT;
|
||||
}
|
||||
|
||||
if ($deckSetting === self::DECK_OFF) {
|
||||
return false;
|
||||
}
|
||||
if ($deckSetting === self::DECK_ALL || $isOwnerOfUnsharedBoard) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return is_callable($isAssigned) ? (bool)$isAssigned() : $isAssigned;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\WorkflowDeckAutomation\Service;
|
||||
|
||||
use OCA\Deck\Db\Card;
|
||||
use OCA\WorkflowDeckAutomation\AppInfo\Application;
|
||||
use OCA\WorkflowDeckAutomation\Db\Workflow;
|
||||
use OCP\App\IAppManager;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
use OCP\IUser;
|
||||
use OCP\Notification\IManager;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Nextcloud notifications (the bell, and from there push to the mobile and
|
||||
* desktop clients) — the counterpart to NotificationMailer. Only the raw
|
||||
* values are stored here; Notification\Notifier turns them into text when a
|
||||
* client fetches the notification.
|
||||
*
|
||||
* Without the notifications app nothing is sent and nothing is logged as an
|
||||
* error: the app is optional, and IManager would just drop the notification.
|
||||
*/
|
||||
class NotificationPusher {
|
||||
public const SUBJECT_CARD_MOVED = 'card_moved';
|
||||
public const SUBJECT_WORKFLOW_DISABLED = 'workflow_disabled';
|
||||
|
||||
public const OBJECT_CARD = 'card';
|
||||
public const OBJECT_WORKFLOW = 'workflow';
|
||||
|
||||
private const NOTIFICATIONS_APP = 'notifications';
|
||||
|
||||
public function __construct(
|
||||
private IManager $notificationManager,
|
||||
private IAppManager $appManager,
|
||||
private ITimeFactory $timeFactory,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
public function isAvailableFor(IUser $user): bool {
|
||||
try {
|
||||
return $this->appManager->isEnabledForUser(self::NOTIFICATIONS_APP, $user);
|
||||
} catch (Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One notification per card, unlike the mail: each one links its card
|
||||
* and can be dismissed on its own. An older notification about the same
|
||||
* card is removed first, so a card that comes back and falls due again
|
||||
* is not listed twice.
|
||||
*
|
||||
* @param non-empty-list<array{workflow: Workflow, card: Card, targets: array{board: string, sourceStack: string, targetStack: string}}> $entries
|
||||
*/
|
||||
public function sendCardsMovedNotification(IUser $recipient, array $entries): void {
|
||||
if (!$this->isAvailableFor($recipient)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
$workflow = $entry['workflow'];
|
||||
$card = $entry['card'];
|
||||
$targets = $entry['targets'];
|
||||
|
||||
try {
|
||||
$this->dismiss($recipient, self::OBJECT_CARD, (string)$card->getId(), self::SUBJECT_CARD_MOVED);
|
||||
|
||||
$notification = $this->notificationManager->createNotification();
|
||||
$notification->setApp(Application::APP_ID)
|
||||
->setUser($recipient->getUID())
|
||||
->setDateTime($this->timeFactory->getDateTime())
|
||||
->setObject(self::OBJECT_CARD, (string)$card->getId())
|
||||
->setSubject(self::SUBJECT_CARD_MOVED, [
|
||||
'cardTitle' => (string)$card->getTitle(),
|
||||
'boardId' => $workflow->getBoardId(),
|
||||
'boardTitle' => $targets['board'],
|
||||
'sourceStackId' => $workflow->getSourceStackId(),
|
||||
'sourceStackTitle' => $targets['sourceStack'],
|
||||
'targetStackId' => $workflow->getTargetStackId(),
|
||||
'targetStackTitle' => $targets['targetStack'],
|
||||
]);
|
||||
$this->notificationManager->notify($notification);
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error('Could not notify ' . $recipient->getUID() . ' about moved card ' . $card->getId() . ': ' . $e->getMessage(), [
|
||||
'app' => Application::APP_ID,
|
||||
'exception' => $e,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Companion to NotificationMailer::sendWorkflowDisabledNotification().
|
||||
*
|
||||
* @param WorkflowRunner::TARGET_* $brokenTarget
|
||||
*/
|
||||
public function sendWorkflowDisabledNotification(IUser $user, Workflow $workflow, string $brokenTarget): void {
|
||||
if (!$this->isAvailableFor($user)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$notification = $this->notificationManager->createNotification();
|
||||
$notification->setApp(Application::APP_ID)
|
||||
->setUser($user->getUID())
|
||||
->setDateTime($this->timeFactory->getDateTime())
|
||||
->setObject(self::OBJECT_WORKFLOW, (string)$workflow->getId())
|
||||
->setSubject(self::SUBJECT_WORKFLOW_DISABLED, [
|
||||
'workflowTitle' => (string)$workflow->getTitle(),
|
||||
'brokenTarget' => $brokenTarget,
|
||||
]);
|
||||
$this->notificationManager->notify($notification);
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error('Could not notify ' . $user->getUID() . ' about disabled workflow ' . $workflow->getId() . ': ' . $e->getMessage(), [
|
||||
'app' => Application::APP_ID,
|
||||
'exception' => $e,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function dismiss(IUser $user, string $objectType, string $objectId, string $subject): void {
|
||||
$notification = $this->notificationManager->createNotification();
|
||||
$notification->setApp(Application::APP_ID)
|
||||
->setUser($user->getUID())
|
||||
->setObject($objectType, $objectId)
|
||||
->setSubject($subject);
|
||||
$this->notificationManager->markProcessed($notification);
|
||||
}
|
||||
}
|
||||
+271
-56
@@ -4,6 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace OCA\WorkflowDeckAutomation\Service;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DateTimeInterface;
|
||||
use DateTimeZone;
|
||||
use OCA\Deck\Db\Card;
|
||||
use OCA\WorkflowDeckAutomation\Db\Workflow;
|
||||
use OCA\WorkflowDeckAutomation\Db\WorkflowMapper;
|
||||
@@ -18,12 +21,15 @@ use Throwable;
|
||||
/**
|
||||
* Evaluates all enabled workflows and moves overdue cards.
|
||||
*
|
||||
* Deck's permission checks read the *live* user session, not a value
|
||||
* frozen at construction, and this runner has to evaluate rules for many
|
||||
* different users within a single background-job process. So workflows
|
||||
* are grouped by owner and, for each owner, we temporarily impersonate
|
||||
* that user (push their IUser onto the session, restore afterwards) —
|
||||
* the standard Nextcloud pattern for multi-user cron jobs.
|
||||
* This runner has to evaluate rules for many different users within a
|
||||
* single background-job process, so workflows are grouped by owner and
|
||||
* each owner is impersonated in turn. Impersonation here means two
|
||||
* things, and the second one is the load-bearing half: pushing the IUser
|
||||
* onto the session (the standard Nextcloud cron pattern), *and* pinning
|
||||
* Deck itself to that user via
|
||||
* DeckIntegrationService::beginUserContext(), because Deck's permission
|
||||
* checks never consult the session — they read a uid frozen once per
|
||||
* process. Both are undone in a finally.
|
||||
*/
|
||||
class WorkflowRunner {
|
||||
public const TARGET_BOARD = 'board';
|
||||
@@ -32,10 +38,20 @@ class WorkflowRunner {
|
||||
|
||||
private ?IUser $impersonationRestore = null;
|
||||
|
||||
/**
|
||||
* Moved cards per recipient uid, collected over the whole run. Each
|
||||
* entry's workflow says whether it goes out by mail, as a Nextcloud
|
||||
* notification, or both.
|
||||
*
|
||||
* @var array<string, list<array{workflow: Workflow, card: Card, targets: array{board: string, sourceStack: string, targetStack: string}}>>
|
||||
*/
|
||||
private array $pendingNotices = [];
|
||||
|
||||
public function __construct(
|
||||
private WorkflowMapper $workflowMapper,
|
||||
private DeckIntegrationService $deckService,
|
||||
private NotificationMailer $mailer,
|
||||
private NotificationPusher $pusher,
|
||||
private IUserManager $userManager,
|
||||
private IUserSession $userSession,
|
||||
private IRootFolder $rootFolder,
|
||||
@@ -50,8 +66,11 @@ class WorkflowRunner {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->pendingNotices = [];
|
||||
$byUser = [];
|
||||
$owners = [];
|
||||
foreach ($workflows as $workflow) {
|
||||
$this->followBoardOwner($workflow, $owners);
|
||||
$byUser[$workflow->getUserId()][] = $workflow;
|
||||
}
|
||||
|
||||
@@ -72,6 +91,57 @@ class WorkflowRunner {
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->sendPendingNotices();
|
||||
}
|
||||
|
||||
/**
|
||||
* A workflow belongs to its board's owner, not to whoever created it: if
|
||||
* Deck transferred the board, the workflow moves along to the new owner —
|
||||
* it then runs as them, and they get its mails.
|
||||
*
|
||||
* Only a known owner rewrites `user_id`. A board that is gone keeps its
|
||||
* last owner, so findBrokenTarget() can disable the workflow and mail
|
||||
* them; a Deck error keeps it too, and the run just goes ahead as before.
|
||||
*
|
||||
* @param array<int, ?string> $owners per-run cache, board id => owner
|
||||
*/
|
||||
private function followBoardOwner(Workflow $workflow, array &$owners): void {
|
||||
$boardId = $workflow->getBoardId();
|
||||
if (!array_key_exists($boardId, $owners)) {
|
||||
try {
|
||||
$owners[$boardId] = $this->deckService->findBoardOwner($boardId);
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->warning('Could not determine the owner of board {board}: ' . $e->getMessage(), [
|
||||
'app' => 'workflow_deck_automation',
|
||||
'board' => $boardId,
|
||||
]);
|
||||
$owners[$boardId] = null;
|
||||
}
|
||||
}
|
||||
|
||||
$owner = $owners[$boardId];
|
||||
if ($owner === null || $owner === $workflow->getUserId()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->logger->info('Workflow {id} follows board {board} to its new owner {owner}.', [
|
||||
'app' => 'workflow_deck_automation',
|
||||
'id' => $workflow->getId(),
|
||||
'board' => $boardId,
|
||||
'owner' => $owner,
|
||||
]);
|
||||
$workflow->setUserId($owner);
|
||||
try {
|
||||
$this->workflowMapper->update($workflow);
|
||||
} catch (Throwable $e) {
|
||||
// Not persisted, but this run still goes to the right user; the
|
||||
// next one tries again.
|
||||
$this->logger->warning('Could not store the new owner of workflow {id}: ' . $e->getMessage(), [
|
||||
'app' => 'workflow_deck_automation',
|
||||
'id' => $workflow->getId(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,7 +163,20 @@ class WorkflowRunner {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->impersonate($user);
|
||||
} catch (DeckUnavailableException $e) {
|
||||
// Deck could not be pinned to this user, so every permission
|
||||
// check would be answered for somebody else. Skipping costs one
|
||||
// run; continuing would move the wrong cards or, worse, read a
|
||||
// denial as "the board is gone" and disable a working workflow.
|
||||
$this->logger->error('Could not impersonate {user} towards Deck: ' . $e->getMessage(), [
|
||||
'app' => 'workflow_deck_automation',
|
||||
'user' => $userId,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
foreach ($workflows as $workflow) {
|
||||
// Same reasoning as in run(): one broken workflow must not
|
||||
@@ -115,7 +198,7 @@ class WorkflowRunner {
|
||||
|
||||
private function runWorkflow(IUser $user, Workflow $workflow): void {
|
||||
try {
|
||||
$brokenTarget = $this->findBrokenTarget($workflow);
|
||||
$brokenTarget = $this->findBrokenTarget($user, $workflow);
|
||||
} catch (DeckUnavailableException $e) {
|
||||
// Could not determine it either way — leave the workflow alone
|
||||
// rather than disabling it over a temporary Deck problem.
|
||||
@@ -141,25 +224,129 @@ class WorkflowRunner {
|
||||
return;
|
||||
}
|
||||
|
||||
$filterUserIds = $workflow->getFilterUserIdsArray();
|
||||
$filterLabelIds = $workflow->getFilterLabelIdsArray();
|
||||
$moved = [];
|
||||
$now = $this->timeFactory->getDateTime();
|
||||
|
||||
foreach ($cards as $card) {
|
||||
if (!self::isOverdue($card->getDaysUntilDue())) {
|
||||
if (!self::isOverdue($card->getDuedate(), $now)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$assignedUserIds = $this->deckService->getCardAssignedUserIds($card);
|
||||
$labelIds = $this->deckService->getCardLabelIds($card);
|
||||
if (!self::cardMatchesFilters($assignedUserIds, $labelIds, $filterUserIds, $filterLabelIds)) {
|
||||
continue;
|
||||
}
|
||||
$this->logger->info('Card {card} ("{title}") matches workflow {id}; moving to stack {target}.', [
|
||||
'app' => 'workflow_deck_automation',
|
||||
'card' => $card->getId(),
|
||||
'title' => $card->getTitle(),
|
||||
'id' => $workflow->getId(),
|
||||
'target' => $workflow->getTargetStackId(),
|
||||
]);
|
||||
|
||||
$this->moveAndNotify($user, $workflow, $card);
|
||||
if ($this->moveCard($workflow, $card)) {
|
||||
$moved[] = $card;
|
||||
}
|
||||
}
|
||||
|
||||
$workflow->setLastRun($this->timeFactory->getDateTime());
|
||||
$this->workflowMapper->update($workflow);
|
||||
|
||||
if ($moved !== []) {
|
||||
$this->queueNotices($user, $workflow, $moved);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Works out who hears about the moved cards and parks them in
|
||||
* $pendingNotices; sendPendingNotices() delivers them at the end of the
|
||||
* run. The recipients are the same for mail and notification.
|
||||
*
|
||||
* Notices are decoration, not part of the move: nothing in here may fail
|
||||
* the workflow, and if Deck cannot say who wants one, nobody gets one
|
||||
* rather than people who asked Deck for silence.
|
||||
*
|
||||
* @param Card[] $cards
|
||||
*/
|
||||
private function queueNotices(IUser $owner, Workflow $workflow, array $cards): void {
|
||||
$cardIds = array_map(static fn (Card $card): int => (int)$card->getId(), $cards);
|
||||
|
||||
switch ($workflow->getNotifyMode()) {
|
||||
case NotificationPolicy::MODE_OWNER:
|
||||
$recipients = array_fill_keys($cardIds, [$owner->getUID()]);
|
||||
break;
|
||||
case NotificationPolicy::MODE_BOARD:
|
||||
try {
|
||||
$recipients = $this->deckService->findNotificationRecipients($workflow->getBoardId(), $cardIds);
|
||||
} catch (DeckUnavailableException $e) {
|
||||
$this->logger->warning('No mails or notifications for workflow {id}: ' . $e->getMessage(), [
|
||||
'app' => 'workflow_deck_automation',
|
||||
'id' => $workflow->getId(),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
$targets = $this->deckService->describeTargets(
|
||||
$workflow->getBoardId(),
|
||||
$workflow->getSourceStackId(),
|
||||
$workflow->getTargetStackId(),
|
||||
);
|
||||
|
||||
foreach ($cards as $card) {
|
||||
foreach ($recipients[(int)$card->getId()] ?? [] as $uid) {
|
||||
$this->pendingNotices[$uid][] = [
|
||||
'workflow' => $workflow,
|
||||
'card' => $card,
|
||||
'targets' => $targets,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One mail per recipient and run, however many cards and workflows it
|
||||
* covers — a board with twenty overdue cards and fifteen members would
|
||||
* otherwise send three hundred mails at once. Nextcloud notifications
|
||||
* go out per card instead: they are grouped in the bell anyway, and each
|
||||
* one links its card.
|
||||
*
|
||||
* Runs after the per-user loop, so no impersonation is active any more.
|
||||
*/
|
||||
private function sendPendingNotices(): void {
|
||||
$pending = $this->pendingNotices;
|
||||
$this->pendingNotices = [];
|
||||
|
||||
foreach ($pending as $uid => $entries) {
|
||||
// Same reasoning as in run(): nothing may escape.
|
||||
try {
|
||||
$recipient = $this->userManager->get((string)$uid);
|
||||
if ($recipient === null || !$recipient->isEnabled()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$mailEntries = array_values(array_filter(
|
||||
$entries,
|
||||
static fn (array $entry): bool => NotificationPolicy::sendsMail((string)$entry['workflow']->getNotifyChannel()),
|
||||
));
|
||||
$notificationEntries = array_values(array_filter(
|
||||
$entries,
|
||||
static fn (array $entry): bool => NotificationPolicy::sendsNotification((string)$entry['workflow']->getNotifyChannel()),
|
||||
));
|
||||
|
||||
if ($mailEntries !== []) {
|
||||
$this->mailer->sendCardsMovedNotification($recipient, $mailEntries);
|
||||
}
|
||||
if ($notificationEntries !== []) {
|
||||
$this->pusher->sendCardsMovedNotification($recipient, $notificationEntries);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error('Could not notify {user} about moved cards: ' . $e->getMessage(), [
|
||||
'app' => 'workflow_deck_automation',
|
||||
'user' => $uid,
|
||||
'exception' => $e,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -173,8 +360,8 @@ class WorkflowRunner {
|
||||
* @return self::TARGET_*|null
|
||||
* @throws DeckUnavailableException if Deck could not be asked
|
||||
*/
|
||||
private function findBrokenTarget(Workflow $workflow): ?string {
|
||||
$stackIds = $this->deckService->findStackIds($workflow->getBoardId());
|
||||
private function findBrokenTarget(IUser $user, Workflow $workflow): ?string {
|
||||
$stackIds = $this->deckService->findStackIds($workflow->getBoardId(), $user->getUID());
|
||||
if ($stackIds === null) {
|
||||
return self::TARGET_BOARD;
|
||||
}
|
||||
@@ -203,14 +390,17 @@ class WorkflowRunner {
|
||||
'target' => $brokenTarget,
|
||||
]);
|
||||
|
||||
// Sent regardless of the workflow's notifyEmail setting: that flag is
|
||||
// about moved cards, this is a one-off notice that the automation the
|
||||
// user configured has been switched off. It stays one-off because a
|
||||
// disabled workflow is not picked up again.
|
||||
// Sent to the owner regardless of the workflow's notifyMode and
|
||||
// notifyChannel: those are about moved cards, this is a one-off notice
|
||||
// that the automation the user configured has been switched off — by
|
||||
// mail as always, and in the bell too if the notifications app is
|
||||
// there. It stays one-off because a disabled workflow is not picked
|
||||
// up again.
|
||||
$this->mailer->sendWorkflowDisabledNotification($user, $workflow, $brokenTarget);
|
||||
$this->pusher->sendWorkflowDisabledNotification($user, $workflow, $brokenTarget);
|
||||
}
|
||||
|
||||
private function moveAndNotify(IUser $user, Workflow $workflow, Card $card): void {
|
||||
private function moveCard(Workflow $workflow, Card $card): bool {
|
||||
try {
|
||||
$this->deckService->moveCard($card->getId(), $workflow->getTargetStackId());
|
||||
} catch (Throwable $e) {
|
||||
@@ -219,44 +409,58 @@ class WorkflowRunner {
|
||||
'card' => $card->getId(),
|
||||
'id' => $workflow->getId(),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($workflow->getNotifyEmail()) {
|
||||
$this->mailer->sendCardMovedNotification($user, $workflow, $card);
|
||||
}
|
||||
}
|
||||
|
||||
public static function isOverdue(?int $daysUntilDue): bool {
|
||||
return $daysUntilDue !== null && $daysUntilDue < 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure filter-matching logic (kept static/side-effect free so it can
|
||||
* be unit tested without real Deck objects). Both filters are OR
|
||||
* inside themselves and AND between each other: an empty filter list
|
||||
* means "no restriction on this dimension".
|
||||
*
|
||||
* @param string[] $cardAssignedUserIds
|
||||
* @param int[] $cardLabelIds
|
||||
* @param string[] $filterUserIds
|
||||
* @param int[] $filterLabelIds
|
||||
*/
|
||||
public static function cardMatchesFilters(
|
||||
array $cardAssignedUserIds,
|
||||
array $cardLabelIds,
|
||||
array $filterUserIds,
|
||||
array $filterLabelIds,
|
||||
): bool {
|
||||
if ($filterUserIds !== [] && array_intersect($filterUserIds, $cardAssignedUserIds) === []) {
|
||||
return false;
|
||||
}
|
||||
if ($filterLabelIds !== [] && array_intersect($filterLabelIds, $cardLabelIds) === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->logger->info('Moved card {card} ("{title}") to stack {target} for workflow {id}.', [
|
||||
'app' => 'workflow_deck_automation',
|
||||
'card' => $card->getId(),
|
||||
'title' => $card->getTitle(),
|
||||
'target' => $workflow->getTargetStackId(),
|
||||
'id' => $workflow->getId(),
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares the card's actual due timestamp against $now instead of Deck's own
|
||||
* Card::getDaysUntilDue(), which truncates both sides to midnight before diffing
|
||||
* (DateInterval's %a is a whole-day count) and therefore reports 0 - not overdue -
|
||||
* for any card whose due time has already passed today. That silently held back
|
||||
* every card for up to 24h after it actually fell due; comparing the raw datetime
|
||||
* instead matches what Deck's own UI shows (e.g. "vor 2 Stunden") and has no
|
||||
* day-rounding step to get wrong.
|
||||
*
|
||||
* Both sides are compared as absolute instants, and the due date is first
|
||||
* pinned to UTC via asStoredUtc() — see there for why.
|
||||
*/
|
||||
public static function isOverdue(?DateTimeInterface $duedate, DateTimeInterface $now): bool {
|
||||
return $duedate !== null && self::asStoredUtc($duedate) < $now;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deck's `duedate` is a DATETIME column without a zone that holds UTC
|
||||
* wall-clock time: Deck's frontend sends `toISOString()`, and the value is
|
||||
* written as plain `Y-m-d H:i:s`. Entity hydrates it with
|
||||
* `new \DateTime($string)`, i.e. in PHP's *default* timezone — correct only
|
||||
* because Nextcloud sets that default to UTC during bootstrap. Reading the
|
||||
* wall clock and pinning it to UTC explicitly makes the comparison
|
||||
* independent of that default, so a server or CLI running with a local
|
||||
* default zone cannot shift every due date by its UTC offset.
|
||||
*
|
||||
* Deck itself has two timezone-sensitive paths this deliberately avoids:
|
||||
* Card::getDaysUntilDue() (day-truncated, see isOverdue()) and
|
||||
* CardMapper::findOverdue(), which compares the UTC column against the
|
||||
* database's `NOW()` — the DB session's local time.
|
||||
*/
|
||||
private static function asStoredUtc(DateTimeInterface $duedate): DateTimeImmutable {
|
||||
return new DateTimeImmutable($duedate->format('Y-m-d H:i:s.u'), new DateTimeZone('UTC'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws DeckUnavailableException if Deck cannot be pinned to $user
|
||||
*/
|
||||
private function impersonate(IUser $user): void {
|
||||
$this->impersonationRestore = $this->userSession->getUser();
|
||||
$this->userSession->setUser($user);
|
||||
@@ -269,9 +473,20 @@ class WorkflowRunner {
|
||||
'app' => 'workflow_deck_automation',
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
// The session switch above is necessary but nowhere near
|
||||
// sufficient — Deck's permission checks never look at it. See
|
||||
// DeckIntegrationService::beginUserContext().
|
||||
$this->deckService->beginUserContext($user);
|
||||
} catch (DeckUnavailableException $e) {
|
||||
$this->clearImpersonation();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
private function clearImpersonation(): void {
|
||||
$this->deckService->endUserContext();
|
||||
$this->userSession->setUser($this->impersonationRestore);
|
||||
$this->impersonationRestore = null;
|
||||
}
|
||||
|
||||
@@ -5,17 +5,31 @@ declare(strict_types=1);
|
||||
namespace OCA\WorkflowDeckAutomation\Settings;
|
||||
|
||||
use OCA\WorkflowDeckAutomation\AppInfo\Application;
|
||||
use OCA\WorkflowDeckAutomation\Service\NotificationPusher;
|
||||
use OCP\AppFramework\Http\TemplateResponse;
|
||||
use OCP\IUserSession;
|
||||
use OCP\Settings\ISettings;
|
||||
use OCP\Util;
|
||||
|
||||
class Personal implements ISettings {
|
||||
public function __construct(
|
||||
private NotificationPusher $pusher,
|
||||
private IUserSession $userSession,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getForm(): TemplateResponse {
|
||||
// The bundle names carry the app-id prefix that @nextcloud/vite-config adds.
|
||||
Util::addScript(Application::APP_ID, Application::APP_ID . '-personal-settings');
|
||||
Util::addStyle(Application::APP_ID, Application::APP_ID . '-personal-settings');
|
||||
|
||||
return new TemplateResponse(Application::APP_ID, 'settings/personal', [], '');
|
||||
// Only used for a hint in the form: without the notifications app the
|
||||
// job silently sends no Nextcloud notifications.
|
||||
$user = $this->userSession->getUser();
|
||||
|
||||
return new TemplateResponse(Application::APP_ID, 'settings/personal', [
|
||||
'notificationsAvailable' => $user !== null && $this->pusher->isAvailableFor($user),
|
||||
], '');
|
||||
}
|
||||
|
||||
public function getSection(): string {
|
||||
|
||||
@@ -5,13 +5,11 @@ declare(strict_types=1);
|
||||
namespace OCA\WorkflowDeckAutomation\Settings;
|
||||
|
||||
use OCA\WorkflowDeckAutomation\AppInfo\Application;
|
||||
use OCP\IL10N;
|
||||
use OCP\IURLGenerator;
|
||||
use OCP\Settings\IIconSection;
|
||||
|
||||
class PersonalSection implements IIconSection {
|
||||
public function __construct(
|
||||
private IL10N $l10n,
|
||||
private IURLGenerator $urlGenerator,
|
||||
) {
|
||||
}
|
||||
@@ -21,7 +19,8 @@ class PersonalSection implements IIconSection {
|
||||
}
|
||||
|
||||
public function getName(): string {
|
||||
return $this->l10n->t('Deck workflow automation');
|
||||
// German only, like the rest of this internal app.
|
||||
return 'Deck Workflow-Automatisierung';
|
||||
}
|
||||
|
||||
public function getPriority(): int {
|
||||
@@ -29,6 +28,6 @@ class PersonalSection implements IIconSection {
|
||||
}
|
||||
|
||||
public function getIcon(): string {
|
||||
return $this->urlGenerator->imagePath(Application::APP_ID, 'app-dark.svg');
|
||||
return $this->urlGenerator->imagePath(Application::APP_ID, 'app.svg');
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "workflow_deck_automation",
|
||||
"version": "0.1.0",
|
||||
"version": "35.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Automates moving overdue Deck cards between stacks, configurable per user.",
|
||||
|
||||
+159
-70
@@ -14,17 +14,18 @@
|
||||
<table v-if="workflows.length" class="wfda-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Board</th>
|
||||
<th>Titel</th>
|
||||
<th>Von Stapel</th>
|
||||
<th>Nach Stapel</th>
|
||||
<th>Filter</th>
|
||||
<th>E-Mail</th>
|
||||
<th>Benachrichtigung</th>
|
||||
<th>Aktiv</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="workflow in workflows" :key="workflow.id">
|
||||
<tr v-for="workflow in sortedWorkflows" :key="workflow.id">
|
||||
<td>{{ boardLabel(workflow.boardId) }}</td>
|
||||
<td>
|
||||
{{ workflow.title }}
|
||||
<span v-if="workflowIssues[workflow.id]" class="wfda-issue">
|
||||
@@ -33,16 +34,8 @@
|
||||
</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>{{ notifyLabel(workflow) }}</td>
|
||||
<td>{{ yesNo(workflow.enabled) }}</td>
|
||||
<td class="wfda-actions">
|
||||
<NcButton variant="tertiary" @click="editWorkflow(workflow)">
|
||||
Bearbeiten
|
||||
@@ -75,6 +68,9 @@
|
||||
:reduce="option => option.value"
|
||||
placeholder="Board wählen"
|
||||
@update:model-value="onBoardChange" />
|
||||
<NcNoteCard v-if="!boardOptions.length" type="info">
|
||||
Workflows können nur für Boards angelegt werden, deren Eigentümer du bist.
|
||||
</NcNoteCard>
|
||||
|
||||
<NcSelect
|
||||
v-model="form.sourceStackId"
|
||||
@@ -92,27 +88,45 @@
|
||||
:disabled="!form.boardId"
|
||||
placeholder="Ziel-Stapel wählen" />
|
||||
|
||||
<NcSelect
|
||||
v-model="form.filterUserIds"
|
||||
input-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"
|
||||
input-label="Nur mit Label (optional)"
|
||||
:options="labelOptions"
|
||||
:reduce="option => option.value"
|
||||
:disabled="!form.boardId"
|
||||
multiple
|
||||
placeholder="Alle Labels" />
|
||||
|
||||
<NcCheckboxRadioSwitch v-model="form.notifyEmail">
|
||||
Benachrichtigungs-E-Mail an mich senden
|
||||
<fieldset class="wfda-notify">
|
||||
<legend>Benachrichtigung bei verschobenen Karten</legend>
|
||||
<NcCheckboxRadioSwitch
|
||||
v-for="option in notifyOptions"
|
||||
:key="option.value"
|
||||
v-model="form.notifyMode"
|
||||
type="radio"
|
||||
name="wfda-notify-mode"
|
||||
:value="option.value">
|
||||
{{ option.label }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
<p v-if="form.notifyMode === 'board'" class="wfda-hint">
|
||||
Jedes Board-Mitglied wird entsprechend seiner eigenen
|
||||
Deck-Einstellung „Benachrichtigungen“ für dieses Board informiert: bei „Alle Karten“ immer,
|
||||
bei „Zugewiesene Karten“ nur für Karten, denen es zugewiesen ist, bei „Keine“ nie.
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset v-if="form.notifyMode !== 'off'" class="wfda-notify">
|
||||
<legend>Benachrichtigen per</legend>
|
||||
<NcCheckboxRadioSwitch
|
||||
v-for="option in channelOptions"
|
||||
:key="option.value"
|
||||
v-model="form.notifyChannel"
|
||||
type="radio"
|
||||
name="wfda-notify-channel"
|
||||
:value="option.value">
|
||||
{{ option.label }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
<p class="wfda-hint">
|
||||
Mehrere Karten eines Laufs werden zu einer E-Mail zusammengefasst.
|
||||
Nextcloud-Benachrichtigungen erscheinen pro Karte in der Glocke und als Push-Nachricht
|
||||
in den Nextcloud-Apps auf Handy und Desktop.
|
||||
</p>
|
||||
<NcNoteCard v-if="!props.notificationsAvailable && form.notifyChannel !== 'mail'" type="warning">
|
||||
Die App „Benachrichtigungen“ ist auf dieser Nextcloud nicht aktiviert.
|
||||
Nextcloud-Benachrichtigungen werden deshalb nicht verschickt.
|
||||
</NcNoteCard>
|
||||
</fieldset>
|
||||
|
||||
<NcCheckboxRadioSwitch v-model="form.enabled">
|
||||
Workflow aktiv
|
||||
@@ -148,17 +162,25 @@ import {
|
||||
createWorkflow,
|
||||
deleteWorkflow,
|
||||
fetchBoards,
|
||||
fetchLabels,
|
||||
fetchParticipants,
|
||||
fetchStacks,
|
||||
fetchWorkflows,
|
||||
updateWorkflow,
|
||||
} from './api.js'
|
||||
|
||||
const props = defineProps({
|
||||
// Whether the notifications app is enabled for the current user, from
|
||||
// the data attribute set in templates/settings/personal.php.
|
||||
notificationsAvailable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
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 showForm = ref(false)
|
||||
@@ -167,12 +189,44 @@ const formError = ref('')
|
||||
const editingId = ref(null)
|
||||
|
||||
const stackOptions = ref([])
|
||||
const labelOptions = ref([])
|
||||
const participantOptions = ref([])
|
||||
// Which board the three lists above currently belong to.
|
||||
// Which board the list above currently belongs to.
|
||||
const loadedBoardId = ref(null)
|
||||
|
||||
const boardOptions = computed(() => boards.value.map((board) => ({ value: board.id, label: board.title })))
|
||||
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
|
||||
|
||||
// The backend only returns boards the user owns. Archived ones are in there
|
||||
// so their workflows keep a board name, but are no target for new ones.
|
||||
const boardOptions = computed(() => boards.value
|
||||
.filter((board) => !board.archived || board.id === form.boardId)
|
||||
.map((board) => ({ value: board.id, label: board.title }))
|
||||
.sort((a, b) => collator.compare(a.label, b.label)))
|
||||
|
||||
/**
|
||||
* Alphabetical by the columns as displayed, left to right: board, title,
|
||||
* source stack, target stack, notification, active.
|
||||
*/
|
||||
const sortedWorkflows = computed(() => {
|
||||
const sortKeys = (workflow) => [
|
||||
boardLabel(workflow.boardId),
|
||||
workflow.title,
|
||||
stackLabel(workflow.boardId, workflow.sourceStackId),
|
||||
stackLabel(workflow.boardId, workflow.targetStackId),
|
||||
notifyLabel(workflow),
|
||||
yesNo(workflow.enabled),
|
||||
]
|
||||
return workflows.value
|
||||
.map((workflow) => ({ workflow, keys: sortKeys(workflow) }))
|
||||
.sort((a, b) => {
|
||||
for (let i = 0; i < a.keys.length; i++) {
|
||||
const result = collator.compare(a.keys[i], b.keys[i])
|
||||
if (result !== 0) {
|
||||
return result
|
||||
}
|
||||
}
|
||||
return a.workflow.id - b.workflow.id
|
||||
})
|
||||
.map((entry) => entry.workflow)
|
||||
})
|
||||
|
||||
// Source and target must differ, so each dropdown hides what the other one
|
||||
// already holds — the error message on save stays as the backstop for
|
||||
@@ -195,10 +249,7 @@ 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')
|
||||
@@ -210,6 +261,7 @@ const workflowIssues = computed(() => {
|
||||
issues[workflow.id] = `${missing.join(' und ')} nicht mehr vorhanden`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return issues
|
||||
})
|
||||
@@ -222,13 +274,44 @@ function emptyForm() {
|
||||
boardId: null,
|
||||
sourceStackId: null,
|
||||
targetStackId: null,
|
||||
filterUserIds: [],
|
||||
filterLabelIds: [],
|
||||
notifyEmail: false,
|
||||
notifyMode: 'off',
|
||||
notifyChannel: 'mail',
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Values match NotificationPolicy::MODES on the server.
|
||||
const notifyOptions = [
|
||||
{ value: 'off', label: 'Keine Benachrichtigung', short: 'Keine' },
|
||||
{ value: 'owner', label: 'Nur an mich', short: 'Nur ich' },
|
||||
{ value: 'board', label: 'An Board-Mitglieder laut ihrer Deck-Einstellung', short: 'Board-Mitglieder' },
|
||||
]
|
||||
|
||||
// Values match NotificationPolicy::CHANNELS on the server.
|
||||
const channelOptions = [
|
||||
{ value: 'mail', label: 'E-Mail', short: 'E-Mail' },
|
||||
{ value: 'notification', label: 'Nextcloud-Benachrichtigung (Push)', short: 'Push' },
|
||||
{ value: 'both', label: 'E-Mail und Nextcloud-Benachrichtigung', short: 'E-Mail + Push' },
|
||||
]
|
||||
|
||||
function notifyLabel(workflow) {
|
||||
const mode = notifyOptions.find((option) => option.value === workflow.notifyMode)?.short ?? workflow.notifyMode
|
||||
if (workflow.notifyMode === 'off') {
|
||||
return mode
|
||||
}
|
||||
const channel = channelOptions.find((option) => option.value === workflow.notifyChannel)?.short ?? workflow.notifyChannel
|
||||
return `${mode} (${channel})`
|
||||
}
|
||||
|
||||
function boardLabel(boardId) {
|
||||
const board = boards.value.find((entry) => entry.id === boardId)
|
||||
return board ? board.title : `#${boardId}`
|
||||
}
|
||||
|
||||
function yesNo(value) {
|
||||
return value ? 'Ja' : 'Nein'
|
||||
}
|
||||
|
||||
function stackLabel(boardId, stackId) {
|
||||
const stacks = stacksByBoard[boardId]
|
||||
if (!stacks) {
|
||||
@@ -242,11 +325,10 @@ 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)
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
@@ -265,31 +347,25 @@ async function loadAll() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills the stack/label/user dropdowns for a board without touching the
|
||||
* current selection — that separation matters: editing a workflow has to
|
||||
* load the same options but must keep the stored stacks and filters.
|
||||
* Fills the stack dropdowns for a board without touching the current
|
||||
* selection — that separation matters: editing a workflow has to load the
|
||||
* same options but must keep the stored stacks.
|
||||
*/
|
||||
async function loadBoardOptions(boardId) {
|
||||
stackOptions.value = []
|
||||
labelOptions.value = []
|
||||
participantOptions.value = []
|
||||
loadedBoardId.value = boardId ?? null
|
||||
|
||||
if (!boardId) {
|
||||
return
|
||||
}
|
||||
|
||||
// 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(() => []),
|
||||
])
|
||||
const stacks = await fetchStacks(boardId).catch(() => null)
|
||||
|
||||
// Feed the overview's cache 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 }))
|
||||
|
||||
stackOptions.value = (stacks ?? []).map((stack) => ({ value: stack.id, label: stack.title }))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -304,8 +380,6 @@ async function onBoardChange(boardId) {
|
||||
|
||||
form.sourceStackId = null
|
||||
form.targetStackId = null
|
||||
form.filterUserIds = []
|
||||
form.filterLabelIds = []
|
||||
|
||||
await loadBoardOptions(boardId)
|
||||
}
|
||||
@@ -324,9 +398,8 @@ async function editWorkflow(workflow) {
|
||||
boardId: workflow.boardId,
|
||||
sourceStackId: workflow.sourceStackId,
|
||||
targetStackId: workflow.targetStackId,
|
||||
filterUserIds: [...workflow.filterUserIds],
|
||||
filterLabelIds: [...workflow.filterLabelIds],
|
||||
notifyEmail: workflow.notifyEmail,
|
||||
notifyMode: workflow.notifyMode,
|
||||
notifyChannel: workflow.notifyChannel || 'mail',
|
||||
enabled: workflow.enabled,
|
||||
})
|
||||
editingId.value = workflow.id
|
||||
@@ -434,6 +507,22 @@ onMounted(loadAll)
|
||||
border-radius: var(--border-radius-large);
|
||||
}
|
||||
|
||||
.wfda-notify {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.wfda-notify legend {
|
||||
font-weight: bold;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.wfda-hint {
|
||||
color: var(--color-text-maxcontrast);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.wfda-form-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
-10
@@ -33,13 +33,3 @@ export async function fetchStacks(boardId) {
|
||||
const { data } = await axios.get(ocsUrl(`/boards/${boardId}/stacks`))
|
||||
return data.ocs.data
|
||||
}
|
||||
|
||||
export async function fetchLabels(boardId) {
|
||||
const { data } = await axios.get(ocsUrl(`/boards/${boardId}/labels`))
|
||||
return data.ocs.data
|
||||
}
|
||||
|
||||
export async function fetchParticipants(boardId) {
|
||||
const { data } = await axios.get(ocsUrl(`/boards/${boardId}/participants`))
|
||||
return data.ocs.data
|
||||
}
|
||||
+3
-1
@@ -3,5 +3,7 @@ import PersonalSettings from './PersonalSettings.vue'
|
||||
|
||||
const mountPoint = document.getElementById('workflow-deck-automation-personal-settings')
|
||||
if (mountPoint) {
|
||||
createApp(PersonalSettings).mount(mountPoint)
|
||||
createApp(PersonalSettings, {
|
||||
notificationsAvailable: mountPoint.dataset.notificationsAvailable === '1',
|
||||
}).mount(mountPoint)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
/** @var array $_ */
|
||||
?>
|
||||
<div id="workflow-deck-automation-personal-settings"></div>
|
||||
<div id="workflow-deck-automation-personal-settings"
|
||||
data-notifications-available="<?php p($_['notificationsAvailable'] ? '1' : '0'); ?>"></div>
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\WorkflowDeckAutomation\Tests\Unit\Service;
|
||||
|
||||
use OCA\WorkflowDeckAutomation\Service\NotificationPolicy;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class NotificationPolicyTest extends TestCase {
|
||||
public function testOffNeverNotifies(): void {
|
||||
$this->assertFalse(NotificationPolicy::shouldNotify('off', false, true));
|
||||
// Not even the owner of an unshared board — "off" is "off" in Deck too.
|
||||
$this->assertFalse(NotificationPolicy::shouldNotify('off', true, true));
|
||||
}
|
||||
|
||||
public function testAllAlwaysNotifies(): void {
|
||||
$this->assertTrue(NotificationPolicy::shouldNotify('all', false, false));
|
||||
$this->assertTrue(NotificationPolicy::shouldNotify('all', true, false));
|
||||
}
|
||||
|
||||
public function testAssignedNotifiesOnlyAssignedUsers(): void {
|
||||
$this->assertTrue(NotificationPolicy::shouldNotify('assigned', false, true));
|
||||
$this->assertFalse(NotificationPolicy::shouldNotify('assigned', false, false));
|
||||
}
|
||||
|
||||
public function testOwnerOfUnsharedBoardIsNotifiedWithAssigned(): void {
|
||||
$this->assertTrue(NotificationPolicy::shouldNotify('assigned', true, false));
|
||||
}
|
||||
|
||||
public function testUnknownSettingFallsBackToDeckDefault(): void {
|
||||
$this->assertTrue(NotificationPolicy::shouldNotify('', false, true));
|
||||
$this->assertFalse(NotificationPolicy::shouldNotify('bogus', false, false));
|
||||
$this->assertTrue(NotificationPolicy::shouldNotify('bogus', true, false));
|
||||
}
|
||||
|
||||
public function testAssignmentIsOnlyLookedUpWhenItMatters(): void {
|
||||
$lookup = function (): bool {
|
||||
$this->fail('assignment lookup must not run');
|
||||
};
|
||||
$this->assertFalse(NotificationPolicy::shouldNotify('off', false, $lookup));
|
||||
$this->assertTrue(NotificationPolicy::shouldNotify('all', false, $lookup));
|
||||
$this->assertTrue(NotificationPolicy::shouldNotify('assigned', true, $lookup));
|
||||
|
||||
$this->assertTrue(NotificationPolicy::shouldNotify('assigned', false, static fn (): bool => true));
|
||||
$this->assertFalse(NotificationPolicy::shouldNotify('assigned', false, static fn (): bool => false));
|
||||
}
|
||||
|
||||
public function testModes(): void {
|
||||
foreach (['off', 'owner', 'board'] as $mode) {
|
||||
$this->assertTrue(NotificationPolicy::isValidMode($mode));
|
||||
}
|
||||
$this->assertFalse(NotificationPolicy::isValidMode(''));
|
||||
$this->assertFalse(NotificationPolicy::isValidMode('all'));
|
||||
}
|
||||
|
||||
public function testChannels(): void {
|
||||
foreach (['mail', 'notification', 'both'] as $channel) {
|
||||
$this->assertTrue(NotificationPolicy::isValidChannel($channel));
|
||||
}
|
||||
$this->assertFalse(NotificationPolicy::isValidChannel(''));
|
||||
$this->assertFalse(NotificationPolicy::isValidChannel('push'));
|
||||
}
|
||||
|
||||
public function testChannelDecidesMailAndNotification(): void {
|
||||
$this->assertTrue(NotificationPolicy::sendsMail('mail'));
|
||||
$this->assertFalse(NotificationPolicy::sendsNotification('mail'));
|
||||
|
||||
$this->assertFalse(NotificationPolicy::sendsMail('notification'));
|
||||
$this->assertTrue(NotificationPolicy::sendsNotification('notification'));
|
||||
|
||||
$this->assertTrue(NotificationPolicy::sendsMail('both'));
|
||||
$this->assertTrue(NotificationPolicy::sendsNotification('both'));
|
||||
}
|
||||
|
||||
public function testUnknownChannelFallsBackToMail(): void {
|
||||
$this->assertTrue(NotificationPolicy::sendsMail(''));
|
||||
$this->assertFalse(NotificationPolicy::sendsNotification(''));
|
||||
$this->assertTrue(NotificationPolicy::sendsMail('bogus'));
|
||||
$this->assertFalse(NotificationPolicy::sendsNotification('bogus'));
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\WorkflowDeckAutomation\Tests\Unit\Service;
|
||||
|
||||
use OCA\WorkflowDeckAutomation\Service\WorkflowRunner;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class WorkflowRunnerFilterTest extends TestCase {
|
||||
public function testIsOverdueReturnsFalseWithoutDueDate(): void {
|
||||
$this->assertFalse(WorkflowRunner::isOverdue(null));
|
||||
}
|
||||
|
||||
public function testIsOverdueReturnsFalseForFutureDueDate(): void {
|
||||
$this->assertFalse(WorkflowRunner::isOverdue(0));
|
||||
$this->assertFalse(WorkflowRunner::isOverdue(3));
|
||||
}
|
||||
|
||||
public function testIsOverdueReturnsTrueForPastDueDate(): void {
|
||||
$this->assertTrue(WorkflowRunner::isOverdue(-1));
|
||||
$this->assertTrue(WorkflowRunner::isOverdue(-30));
|
||||
}
|
||||
|
||||
public function testNoFiltersMatchesAnyCard(): void {
|
||||
$this->assertTrue(WorkflowRunner::cardMatchesFilters(['alice'], [1, 2], [], []));
|
||||
$this->assertTrue(WorkflowRunner::cardMatchesFilters([], [], [], []));
|
||||
}
|
||||
|
||||
public function testUserFilterIsOrAgainstAssignedUsers(): void {
|
||||
$this->assertTrue(WorkflowRunner::cardMatchesFilters(['alice', 'bob'], [], ['bob', 'carol'], []));
|
||||
$this->assertFalse(WorkflowRunner::cardMatchesFilters(['alice'], [], ['bob', 'carol'], []));
|
||||
}
|
||||
|
||||
public function testLabelFilterIsOrAgainstCardLabels(): void {
|
||||
$this->assertTrue(WorkflowRunner::cardMatchesFilters([], [1, 2], [], [2, 3]));
|
||||
$this->assertFalse(WorkflowRunner::cardMatchesFilters([], [1], [], [2, 3]));
|
||||
}
|
||||
|
||||
public function testUserAndLabelFiltersAreCombinedWithAnd(): void {
|
||||
// user matches, label does not -> overall false
|
||||
$this->assertFalse(WorkflowRunner::cardMatchesFilters(['alice'], [1], ['alice'], [99]));
|
||||
// both match -> true
|
||||
$this->assertTrue(WorkflowRunner::cardMatchesFilters(['alice'], [1], ['alice'], [1]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\WorkflowDeckAutomation\Tests\Unit\Service;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use OCA\WorkflowDeckAutomation\Service\WorkflowRunner;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class WorkflowRunnerTest extends TestCase {
|
||||
private static function utc(string $time): DateTimeImmutable {
|
||||
return new DateTimeImmutable($time, new DateTimeZone('UTC'));
|
||||
}
|
||||
|
||||
public function testIsOverdueReturnsFalseWithoutDueDate(): void {
|
||||
$this->assertFalse(WorkflowRunner::isOverdue(null, self::utc('2026-01-15 12:00:00')));
|
||||
}
|
||||
|
||||
public function testIsOverdueReturnsFalseForFutureDueDate(): void {
|
||||
$now = self::utc('2026-01-15 12:00:00');
|
||||
$this->assertFalse(WorkflowRunner::isOverdue($now->modify('+1 hour'), $now));
|
||||
$this->assertFalse(WorkflowRunner::isOverdue($now->modify('+3 days'), $now));
|
||||
}
|
||||
|
||||
public function testIsOverdueReturnsTrueForPastDueDate(): void {
|
||||
$now = self::utc('2026-01-15 12:00:00');
|
||||
$this->assertTrue(WorkflowRunner::isOverdue($now->modify('-1 hour'), $now));
|
||||
$this->assertTrue(WorkflowRunner::isOverdue($now->modify('-30 days'), $now));
|
||||
}
|
||||
|
||||
public function testIsOverdueReturnsTrueForADueTimeEarlierTheSameDay(): void {
|
||||
// Regression: comparing whole calendar days (as Deck's own
|
||||
// Card::getDaysUntilDue() does) reports a card due earlier today as
|
||||
// "0 days until due", not overdue, until the calendar date rolls
|
||||
// over - holding every card back for up to 24h after it actually
|
||||
// fell due. isOverdue() must compare the real timestamps instead.
|
||||
$now = self::utc('2026-01-15 12:00:00');
|
||||
$this->assertTrue(WorkflowRunner::isOverdue(self::utc('2026-01-15 06:00:00'), $now));
|
||||
}
|
||||
|
||||
public function testIsOverdueReadsTheStoredWallClockAsUtc(): void {
|
||||
// What Entity hydration produces if PHP's default zone is not UTC:
|
||||
// the stored UTC wall clock "12:00", tagged with the local zone.
|
||||
$due = new DateTimeImmutable('2026-07-15 12:00:00', new DateTimeZone('Europe/Berlin'));
|
||||
|
||||
// Taken at face value that would be 10:00 UTC and already overdue.
|
||||
$this->assertFalse(WorkflowRunner::isOverdue($due, self::utc('2026-07-15 11:30:00')));
|
||||
$this->assertTrue(WorkflowRunner::isOverdue($due, self::utc('2026-07-15 12:00:01')));
|
||||
}
|
||||
|
||||
public function testIsOverdueComparesNowAsAnAbsoluteInstant(): void {
|
||||
$due = self::utc('2026-07-15 12:00:00');
|
||||
$berlin = new DateTimeZone('Europe/Berlin');
|
||||
|
||||
// 13:30 in Berlin (CEST) is 11:30 UTC - not yet due.
|
||||
$this->assertFalse(WorkflowRunner::isOverdue($due, new DateTimeImmutable('2026-07-15 13:30:00', $berlin)));
|
||||
// 14:30 in Berlin is 12:30 UTC - due.
|
||||
$this->assertTrue(WorkflowRunner::isOverdue($due, new DateTimeImmutable('2026-07-15 14:30:00', $berlin)));
|
||||
}
|
||||
|
||||
public function testIsOverdueIsFalseAtTheExactDueInstant(): void {
|
||||
$now = self::utc('2026-01-15 12:00:00');
|
||||
$this->assertFalse(WorkflowRunner::isOverdue($now, $now));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user