Tauschanfragen für Teammitglieder ergänzen
Betrachter klicken ihren Tag an, wählen ihren Namen, optional Wunschperson und Wunschtag, und schreiben eine Notiz; keine Anmeldung nötig, zehn Anfragen je Stunde und Adresse. Offene Anfragen markieren den Tag im Kalender. Bearbeiter sehen sie unter „Anfragen" mit Zähler und führen den Tausch aus (über Kreuz oder Übernahme), öffnen den Tag oder lehnen ab. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
3aa37033fd
commit
6a3c68aaac
9 files changed
+495
-29
No files matched your search
+125
-24
@@ -11,15 +11,97 @@ prüft, das du gar nicht gepusht hast.
|
||||
## 1. Zwei Maschinen, nicht eine
|
||||
|
||||
- **Hier (DEV):** ein Debian-LXC. Nutzer `vchuser`, Heimat `/home/vchuser`, Projekte unter
|
||||
`/home/vchuser/projects/<name>`. code-server auf Port 8080, Claude Code als `claude`,
|
||||
Git gegen Gitea vorkonfiguriert.
|
||||
- **Dort (PROD):** eine **andere** Maschine. VCH klont dein Repository dorthin und startet es.
|
||||
`/home/vchuser/projects/<name>`. `vchuser` hat `sudo` ohne Passwort und ist in der
|
||||
Docker-Gruppe. Vorhanden: Node 20 (npm; pnpm und yarn sind NICHT installiert — `corepack
|
||||
enable` holt sie, wenn dein Projekt sie braucht), Python samt venv, Docker, tmux, git,
|
||||
code-server auf Port 8080, Claude Code als `claude`, OpenCode als `opencode`; Git gegen Gitea
|
||||
vorkonfiguriert. Das Terminal läuft in tmux — ein Dev-Server im Vordergrund blockiert die
|
||||
Sitzung, starte ihn in einem eigenen tmux-Fenster oder mit `&`.
|
||||
- **Dort (PROD):** eine **andere** Maschine mit demselben Werkzeugsatz. VCH klont dein
|
||||
Repository dorthin und startet es. Der Build läuft dort mit **gelöschtem `NODE_ENV`**, damit
|
||||
devDependencies installiert werden; ein `NODE_ENV=production` aus `.env.example` gilt erst
|
||||
zur Laufzeit.
|
||||
|
||||
Daraus folgt die wichtigste Regel überhaupt: **VCH deployt und auditiert deinen gepushten
|
||||
Commit, nie dein Arbeitsverzeichnis.** Nicht committete Änderungen existieren für Deploy,
|
||||
Audit und Veröffentlichung nicht — auch dann nicht, wenn hier alles läuft.
|
||||
|
||||
## 2. Vorschau im Browser läuft über einen Proxy
|
||||
## 2. Wegweiser — welcher Weg in welcher Lage
|
||||
|
||||
Bevor du baust: such deine Lage, nimm den Weg, kenn die Folgen. Die Abschnitte dahinter
|
||||
erklären das Wie.
|
||||
|
||||
**Eine Datenbank, Schlüssel in `.env.example`.** Der Mensch stellt sie im Projektarbeitsplatz
|
||||
bereit, VCH trägt die Werte in genau deine Schlüssel ein (Abschnitt 8). Folgen: Datenbank und
|
||||
Benutzer heißen `app`, sofern beim Anlegen nichts anderes gewählt wurde; Migrationen laufen in
|
||||
deinem `build` oder `start`, VCH führt keine aus; DB-Zeilen im Env-Editor werden gestrichen,
|
||||
solange die Datenbank läuft; erst der nächste Deploy trägt die Werte ein.
|
||||
<!-- Beleg: prodDatabase.ts provision (DB_DEFAULT_NAME), prodEnvFiles.ts stripManagedLines -->
|
||||
|
||||
**Zwei Datenbanken — oder Redis, Mongo, Elasticsearch.** Compose mit eigenen Diensten
|
||||
(Abschnitt 13). Folgen: ein Compose mit eigener Datenbank deployt **nicht blau-grün, sondern
|
||||
in place** — kurze Auszeit beim Umschalten, das Audit läuft als Torprüfung vor dem Start; VCH
|
||||
sichert diese Datenbank nicht (kein Backup über die Karte); jeder Datenbankdienst braucht ein
|
||||
Named Volume; den DB-Port **nicht** als festen Host-Port veröffentlichen. An der verwalteten
|
||||
Datenbank landen zwei deklarierte Verbindungen (`DATABASE_URL` und `ANALYTICS_DATABASE_URL`)
|
||||
auf **derselben** — deshalb Compose.
|
||||
<!-- Beleg: deploy.ts skipBlueGreen (composeHasDbService), dbEnvRecognizer.ts recognizeDbEnv -->
|
||||
|
||||
**Nur Dateien oder SQLite.** Keine Datenbank; alles, was bleiben soll, nach `VCH_DATA_DIR`
|
||||
(Abschnitt 9). Folgen: `HISTORY_DB=/pfad.sqlite` ist bewusst keine Verbindung und wird nicht
|
||||
überschrieben; ein Pfad relativ zum Release ist beim nächsten Deploy weg.
|
||||
<!-- Beleg: dbEnvRecognizer.ts classify (nameSuffix-Regel) -->
|
||||
|
||||
**Eine Datenbank zum Entwickeln, hier auf DEV.** VCH stellt hier keine bereit. Starte selbst
|
||||
eine — Docker ist da, `vchuser` ist in der Docker-Gruppe:
|
||||
`docker run -d --name dev-db -p 127.0.0.1:5432:5432 -e POSTGRES_PASSWORD=dev postgres:16`.
|
||||
Lege eine lokale `.env` an, deklariere die Schlüssel in `.env.example`. Folgen: die Bindung an
|
||||
`127.0.0.1` ist Pflicht — ohne Adresse vor dem Port lauscht die Datenbank für jeden im LAN; die
|
||||
`.env` gehört nicht ins Repository; für PROD trägt VCH die verwaltete Datenbank ein.
|
||||
<!-- Beleg: template-build.ts vchuser (usermod -aG docker, sudoers NOPASSWD) -->
|
||||
|
||||
**Ein zweiter Prozess neben der App** (Worker, Cron, Queue). Compose (Abschnitt 13). Folgen:
|
||||
systemd startet genau **einen** Befehl, den aus `start`; im Compose läuft jeder Dienst mit,
|
||||
Dienste hinter `profiles:` bleiben aus.
|
||||
<!-- Beleg: deployCommands.ts renderSystemdUnit (ein ExecStart) -->
|
||||
|
||||
**Reines Frontend ohne Server.** Statisch ausliefern, irgendetwas muss auf `$PORT` lauschen
|
||||
(Abschnitt 6.2, „Statische Seite"). Folgen: relative Pfade und Vite `base: './'` (Abschnitt 3),
|
||||
sonst bricht die Vorschau hinter dem Pfad-Proxy.
|
||||
<!-- Beleg: Abschnitt 6.2 dieses Textes; routes/proxy.ts Pfadpräfix -->
|
||||
|
||||
**Code in einem Unterordner.** `cd` in `build` und `start` (Abschnitt 6.2, „Monorepo"), die
|
||||
`.env.example` in den Ordner, der sie liest. Folgen: jede Beispieldatei bestimmt ihr eigenes
|
||||
`.env`-Ziel (Abschnitt 7).
|
||||
<!-- Beleg: prodEnvFiles.ts envFilePathForExample -->
|
||||
|
||||
**Die Vorschau zeigt nichts, der Port wird aber erkannt.** Dein Server lauscht auf `127.0.0.1`.
|
||||
Lausche auf `0.0.0.0`: `vite --host`, `next dev -H 0.0.0.0`, `HOST=0.0.0.0`. Folgen: die
|
||||
Porterkennung sieht auch lokale Lauscher, der Proxy erreicht sie nicht — ein erkannter Port
|
||||
beweist keine erreichbare Vorschau.
|
||||
<!-- Beleg: routes/lxc.ts GET /instances/:id/ports (ss -tlnp ohne Bindeadresse), routes/proxy.ts target instance.ip -->
|
||||
|
||||
**Ein Geheimnis wird gebraucht** (API-Schlüssel, Token). In `.env.example` **leer** deklarieren
|
||||
(`API_KEY=`); den Wert hinterlegt der Mensch im Env-Editor (Abschnitt 7). Folgen: leer ohne
|
||||
Vorgabe blockiert den Deploy, bis der Wert da ist — gewollt; die Prüfung `secrets` findet
|
||||
eingecheckte Werte (Abschnitt 11).
|
||||
<!-- Beleg: Abschnitt 7 dieses Textes (blockierende Schlüssel); AUDIT_RUNNER_MANIFEST secrets -->
|
||||
|
||||
**Der Deploy schlägt fehl.** Du siehst weder das Deploy-Log noch Logs der PROD-Maschine — lass
|
||||
dir den Fehlertext aus dem Projektarbeitsplatz geben. Dann prüfen: lauscht die App auf `PORT`,
|
||||
antwortet der `health`-Pfad innerhalb von 30 Sekunden, liest sie ihre Werte aus der Umgebung?
|
||||
Folgen: im Deploy-Log stehen die letzten 50 Logzeilen der App — logge nach stdout, nicht in
|
||||
Dateien.
|
||||
<!-- Beleg: deployCommands.ts buildJournalTailCmd (-n 50) -->
|
||||
|
||||
**Dem Projekt ist eine Integration zugeordnet.** Die Zugangsdaten liegen in
|
||||
`.vch-integrations.env` im Projektstamm: `<NAME>_API_URL`, `<NAME>_USER`, `<NAME>_PASSWORD`,
|
||||
`<NAME>_TOKEN`. Folgen: die Datei liegt **nur hier auf DEV**, im Klartext, wird nicht deployt
|
||||
und ist nicht ignoriert — trag sie in `.gitignore` ein und committe sie nie; für PROD dieselben
|
||||
Schlüssel in `.env.example` deklarieren, die Werte in den Env-Editor.
|
||||
<!-- Beleg: routes/integrations.ts buildEnvFile/deployEnvFile; kein Verweis in deploy.ts (gemessen 2026-09-17) -->
|
||||
|
||||
## 3. Vorschau im Browser läuft über einen Proxy
|
||||
|
||||
Deine Entwicklungs-Server sind nicht direkt erreichbar. VCH erkennt lauschende Ports und
|
||||
stellt zwei Wege bereit:
|
||||
@@ -39,14 +121,14 @@ Rahmenwerke: **Vite** `base: './'` · **Create React App** `"homepage": "."` in
|
||||
`package.json` · **Next.js** Vorgabe belassen (Subdomain) oder `basePath` setzen (Pfad-Proxy)
|
||||
· **Express** `express.static` genügt, solange das HTML relativ verweist.
|
||||
|
||||
## 3. Dateien, die VCH im Projektstamm liest
|
||||
## 4. Dateien, die VCH im Projektstamm liest
|
||||
|
||||
| Datei | Pflicht | Wozu |
|
||||
|---|---|---|
|
||||
| `README.md` | ja | Audit-Prüfung `readme` — blockiert die Veröffentlichung |
|
||||
| `.vch-description` | ja | Audit-Prüfung `description`; der Text steht später im Showcase |
|
||||
| `.env.example` | wenn die App Umgebungsvariablen liest | die **Deklaration** aller Variablen (Abschnitt 6) |
|
||||
| `.vch/deploy.yaml` | wenn die Autoerkennung nicht reicht | wie gebaut und gestartet wird (Abschnitt 5) |
|
||||
| `.env.example` | wenn die App Umgebungsvariablen liest | die **Deklaration** aller Variablen (Abschnitt 7) |
|
||||
| `.vch/deploy.yaml` | wenn die Autoerkennung nicht reicht | wie gebaut und gestartet wird (Abschnitt 6) |
|
||||
| `CLAUDE.md` | — | dein Projektwissen; VCH fasst sie nicht mehr an |
|
||||
|
||||
`README.md` muss beschreiben, **was** das Projekt tut, wie man es installiert, wie man es
|
||||
@@ -56,7 +138,13 @@ startet und worauf es aufbaut. Die unveränderte Vorlage besteht die Prüfung ni
|
||||
für wen. Kein Technologie-Stapel, keine Installationsanleitung — die stehen im README.
|
||||
Halte beide aktuell, wenn sich Zweck oder Aufbau ändern.
|
||||
|
||||
## 4. Der Port gehört VCH
|
||||
`.vch-description` legt VCH beim Anlegen als **Platzhalter** an, der die Prüfung nicht besteht —
|
||||
dass die Datei existiert, heißt nicht, dass sie fertig ist.
|
||||
|
||||
**VCH-eigen, nicht anfassen:** `.vch/PLATFORM.md`, `~/.vch_env`, `~/.config/vch/` und
|
||||
`/etc/profile.d/vch-opencode.sh`. Wer „die Shell-Umgebung aufräumt", kappt sich den AI-Server.
|
||||
|
||||
## 5. Der Port gehört VCH
|
||||
|
||||
VCH weist jedem Projekt auf der PROD-Maschine einen festen Port zu (Bereich `13000–13999`)
|
||||
und hält ihn über alle Deploys hinweg stabil. Du wählst ihn nicht und stimmst dich mit
|
||||
@@ -73,13 +161,13 @@ Ein fest verdrahteter Port läuft hier und scheitert dort am Healthcheck. `PORT`
|
||||
`VCH_DATA_DIR` sind **VCH-eigene** Schlüssel: setzt du sie selbst — in `.env.example`, im
|
||||
Env-Editor oder im Compose — wird dein Wert verworfen.
|
||||
|
||||
## 5. `.vch/deploy.yaml` — die vollständige Referenz
|
||||
## 6. `.vch/deploy.yaml` — die vollständige Referenz
|
||||
|
||||
Ohne diese Datei versucht VCH, das Projekt selbst zu erkennen (Abschnitt 5.3). Reicht das
|
||||
Ohne diese Datei versucht VCH, das Projekt selbst zu erkennen (Abschnitt 6.3). Reicht das
|
||||
nicht, bricht der Deploy ab **und legt dir eine fertige Vorlage vor**, die du nur noch
|
||||
committen musst.
|
||||
|
||||
### 5.1 Alle Felder
|
||||
### 6.1 Alle Felder
|
||||
|
||||
| Feld | Typ | Vorgabe | Bedeutung |
|
||||
|---|---|---|---|
|
||||
@@ -94,7 +182,7 @@ committen musst.
|
||||
Unbekannte Felder und Tippfehler in `runtime` brechen den Deploy mit einer benannten
|
||||
Fehlermeldung ab — sie werden nicht still ignoriert.
|
||||
|
||||
### 5.2 Beispiele
|
||||
### 6.2 Beispiele
|
||||
|
||||
Node mit eigenem Build (systemd):
|
||||
|
||||
@@ -145,7 +233,7 @@ runtime: docker
|
||||
compose: docker/compose.yaml
|
||||
```
|
||||
|
||||
### 5.3 Was ohne die Datei passiert
|
||||
### 6.3 Was ohne die Datei passiert
|
||||
|
||||
In dieser Reihenfolge:
|
||||
|
||||
@@ -155,13 +243,13 @@ In dieser Reihenfolge:
|
||||
App-Dienst), wird als Entwicklungs-Beiwerk erkannt und **nicht** deployt — VCH sucht
|
||||
weiter nach der Sprache.
|
||||
4. Eine Compose-Datei mit App-Dienst, die `${VCH_PORT}` **nicht** veröffentlicht → Abbruch
|
||||
mit Vorlage (Abschnitt 10).
|
||||
mit Vorlage (Abschnitt 13).
|
||||
5. `package.json` im Stamm: `build`-Skript → Build, `start`-Skript bzw. `main` → Start.
|
||||
Paketmanager wird am Lockfile erkannt (`pnpm`/`yarn`/`npm`).
|
||||
6. Alles andere — Python, PHP/Laravel, Go, reines Frontend ohne `start`, Code nur in
|
||||
Unterordnern — endet mit einer passenden Vorlage zum Committen.
|
||||
|
||||
## 6. Umgebungsvariablen: `.env.example` ist die Deklaration
|
||||
## 7. Umgebungsvariablen: `.env.example` ist die Deklaration
|
||||
|
||||
VCH liest keine Umgebungsvariablen aus deinem Code. **Was in einer Beispieldatei steht,
|
||||
existiert; alles andere nicht.**
|
||||
@@ -200,12 +288,25 @@ und jeden Treffer in die `.env.example` schreiben.
|
||||
du passende `ARG`-Zeilen im Dockerfile. Alle übrigen Werte kommen erst zur Laufzeit über
|
||||
`--env-file` und landen nie im Image.
|
||||
|
||||
## 7. Datenbank
|
||||
## 8. Datenbank
|
||||
|
||||
Deklariert deine App Verbindungsvariablen, bietet VCH eine verwaltete Datenbank an und trägt
|
||||
die Werte **automatisch** in genau die Schlüssel ein, die du deklariert hast. Du musst deine
|
||||
Variablennamen also nicht ändern — VCH erkennt sie.
|
||||
|
||||
**Was du bekommst:** genau **eine** verwaltete Datenbank je Projekt — Postgres 16 oder MySQL 8
|
||||
als eigener Container auf der PROD-Maschine, Host `127.0.0.1`, Port aus `15000–15999`, nicht
|
||||
aus dem LAN erreichbar. Datenbank und Benutzer heißen `app`; der Mensch kann beim Anlegen unter
|
||||
„Erweitert" eigene Werte wählen (Kleinbuchstaben, Ziffern, Unterstrich, Buchstabe vorne;
|
||||
reservierte Namen und das Präfix `pg_` sind gesperrt) — **nur beim Anlegen**,
|
||||
danach nicht mehr. Das Passwort wird erzeugt oder gewählt und beim Deploy in deine Schlüssel
|
||||
eingetragen; deklarierst du keine erkennbaren Schlüssel, zeigt die Karte im Projektarbeitsplatz
|
||||
die Verbindung samt Passwort zum Kopieren. Auf DEV gibt es diese Datenbank nicht (Wegweiser).
|
||||
Migrationen macht deine App im `build` oder `start`. Backup: der Abzug liegt im Projekt-Backup,
|
||||
vor dem Löschen zieht VCH einen Pflicht-Abzug. Solange die Datenbank läuft, werden DB-Zeilen im
|
||||
Env-Editor gestrichen — dein eigener Wert hält nicht. Deklarierst du **zwei** Verbindungen,
|
||||
landen beide auf dieser einen Datenbank: dann Compose (Wegweiser).
|
||||
|
||||
**Erkannt wird** (Groß-/Kleinschreibung egal):
|
||||
|
||||
- Verbindungszeichenketten: `DATABASE_URL`, `DB_URL`, jeder Name auf `_DSN`
|
||||
@@ -234,7 +335,7 @@ verliert seine Daten beim nächsten Neubau, und VCH warnt nur.
|
||||
Eine Compose-Datei, die **nur** eine Datenbank startet, ist Entwicklungs-Beiwerk: sie läuft in
|
||||
Produktion nicht mit. Nimm dort die verwaltete Datenbank.
|
||||
|
||||
## 8. Dauerhafte Daten: `VCH_DATA_DIR`
|
||||
## 9. Dauerhafte Daten: `VCH_DATA_DIR`
|
||||
|
||||
Jedes Release liegt in einem eigenen Verzeichnis und verschwindet wieder. Was überleben soll —
|
||||
SQLite-Datei, Uploads, erzeugte Dateien — gehört in das Verzeichnis aus **`VCH_DATA_DIR`**.
|
||||
@@ -247,25 +348,25 @@ VCH setzt die Variable bei allen Betriebsarten (bei Docker als Einhängepunkt `/
|
||||
sie zur Laufzeit**, schreibe den Pfad nirgends fest und trage sie nicht in `.env`-Dateien ein.
|
||||
Ein Verzeichnis relativ zum Release (`./data`) ist beim nächsten Deploy weg.
|
||||
|
||||
Der Prüfling während des Audits (Abschnitt 9) bekommt diesen Speicher **nicht** — er läuft
|
||||
Der Prüfling während des Audits (Abschnitt 10) bekommt diesen Speicher **nicht** — er läuft
|
||||
gegen einen leeren. Das ist Absicht: zwei Prozesse dürfen nicht gleichzeitig auf dieselbe
|
||||
SQLite-Datei schreiben.
|
||||
|
||||
## 9. Wie ein Deploy abläuft
|
||||
## 10. Wie ein Deploy abläuft
|
||||
|
||||
```
|
||||
Klonen → Bauen → Healthcheck (Prüfling) → Audit → Umschalten → Live
|
||||
```
|
||||
|
||||
1. **Klonen.** VCH klont den Deploy-Branch auf die PROD-Maschine — genau den gepushten Stand.
|
||||
2. **Torprüfung.** Fehlt ein blockierender Wert aus `.env.example` (Abschnitt 6), endet es hier,
|
||||
2. **Torprüfung.** Fehlt ein blockierender Wert aus `.env.example` (Abschnitt 7), endet es hier,
|
||||
bevor irgendetwas läuft.
|
||||
3. **Bauen** nach `.vch/deploy.yaml` bzw. Autoerkennung.
|
||||
4. **Healthcheck.** Der neue Stand startet als **Prüfling** auf einem freien Port aus dem
|
||||
Bereich `14000–14999`, neben der weiterhin laufenden alten Fassung. VCH ruft dort deinen
|
||||
`health`-Pfad auf: rund 30 Sekunden lang, alle 1,5 s ein Versuch, Erfolg beim ersten
|
||||
2xx/3xx. Bis dahin muss die App antworten.
|
||||
5. **Audit** (Abschnitt 10) gegen den deployten Commit.
|
||||
5. **Audit** (Abschnitt 11) gegen den deployten Commit.
|
||||
6. **Umschalten.** Erst wenn das Audit besteht, wird der Prüfling zur laufenden Fassung auf
|
||||
dem stabilen Port. **Ein durchgefallenes Audit heißt: der alte Stand bleibt live.**
|
||||
|
||||
@@ -276,7 +377,7 @@ Projektarbeitsplatz per Rollback zurückholen.
|
||||
**Auto-Deploy:** ist er eingeschaltet, löst jeder Push auf den konfigurierten Deploy-Branch
|
||||
einen Deploy aus. Pushes auf andere Branches werden ignoriert.
|
||||
|
||||
## 10. Audit — was geprüft wird
|
||||
## 11. Audit — was geprüft wird
|
||||
|
||||
Das Audit läuft nicht hier, sondern in einem eigenen Runner gegen einen **sauberen Checkout
|
||||
des deployten Commits**. Nicht Gepushtes wird nicht geprüft — und ein `.gitignore`-Eintrag
|
||||
@@ -299,7 +400,7 @@ macht eine Datei für das Audit unsichtbar.
|
||||
- `secrets` sucht eingecheckte Zugangsdaten. Echte Werte gehören nie ins Repository: in die
|
||||
`.env.example` nur Beispiele, die echten Werte in den Env-Editor von VCH.
|
||||
|
||||
## 11. Veröffentlichen
|
||||
## 12. Veröffentlichen
|
||||
|
||||
- **Intern erreichbar** ist dein Projekt, sobald ein Deploy läuft — ohne Audit, ohne weiteren
|
||||
Schritt, unter `http://<prod-ip>:<port>`.
|
||||
@@ -309,7 +410,7 @@ macht eine Datei für das Audit unsichtbar.
|
||||
- Im **Showcase** erscheinen veröffentlichte Projekte mit bestandenem Audit und einer
|
||||
eingecheckten `.vch-description`.
|
||||
|
||||
## 12. Compose — die vier Regeln, an denen es sonst scheitert
|
||||
## 13. Compose — die vier Regeln, an denen es sonst scheitert
|
||||
|
||||
1. **Der App-Dienst muss seinen Port als `ports: ["${VCH_PORT}:<intern>"]` veröffentlichen.**
|
||||
VCH setzt `VCH_PORT` und kann so einen Prüfling neben deiner laufenden Fassung starten.
|
||||
|
||||
@@ -80,6 +80,16 @@ auf „Anmelden". Die Anmeldung gilt 30 Tage im jeweiligen Browser; nach fünf f
|
||||
wartet der Server eine Minute. Ohne `EDIT_PASSWORD` darf jeder alles, wie bei einem Werkzeug
|
||||
für eine einzelne Person.
|
||||
|
||||
## Tauschanfragen
|
||||
|
||||
Wer an seinem Tag nicht kann, klickt den Tag im Kalender an, wählt seinen Namen, optional die
|
||||
Person und den Tag für den Tausch, und schreibt eine Notiz. Dafür ist keine Anmeldung nötig;
|
||||
pro Adresse sind zehn Anfragen je Stunde möglich. Der Tag bekommt im Kalender eine Markierung.
|
||||
Bearbeiter sehen alle Anfragen unter „Anfragen" mit Zähler in der Navigation und entscheiden:
|
||||
„Ausführen" tauscht wie gewünscht (über Kreuz oder Übernahme des Tages), „Tag öffnen" lässt
|
||||
manuell wählen, „Erledigt" und „Ablehnen" schließen die Anfrage ohne Änderung. Nach einem
|
||||
Tausch den Zeitraum erneut nach Nextcloud schreiben.
|
||||
|
||||
## Nextcloud
|
||||
|
||||
1. Umgebungsvariablen setzen und App neu starten.
|
||||
|
||||
+134
-5
@@ -51,7 +51,7 @@ async function api(path, { method = 'GET', body, raw } = {}) {
|
||||
}
|
||||
|
||||
// ---------- Anmeldung zum Bearbeiten ----------
|
||||
const EDITOR_VIEWS = ['personen', 'abwesenheiten', 'einstellungen'];
|
||||
const EDITOR_VIEWS = ['personen', 'abwesenheiten', 'anfragen', 'einstellungen'];
|
||||
function isEditor() { return !state.data || state.data.auth?.editor !== false; }
|
||||
function applyAuthUI() {
|
||||
const a = state.data?.auth || { protected: false, editor: true };
|
||||
@@ -62,6 +62,11 @@ function applyAuthUI() {
|
||||
for (const link of $$('.topbar nav a')) link.hidden = !a.editor && EDITOR_VIEWS.includes(link.dataset.view);
|
||||
$('#open-plan').hidden = !a.editor;
|
||||
$('#open-nextcloud').hidden = !a.editor;
|
||||
$('#open-request').hidden = a.editor;
|
||||
const open = (state.data?.requests || []).filter((r) => r.status === 'offen').length;
|
||||
const badge = $('#requests-count');
|
||||
badge.hidden = !a.editor || open === 0;
|
||||
badge.textContent = open;
|
||||
if (!a.editor && EDITOR_VIEWS.includes(location.hash.slice(1))) location.hash = '#kalender';
|
||||
}
|
||||
function openLogin() {
|
||||
@@ -139,7 +144,7 @@ function avatarEl(p, cls = '') {
|
||||
|
||||
// ---------- Navigation ----------
|
||||
function showView(name) {
|
||||
const known = ['kalender', 'personen', 'abwesenheiten', 'einstellungen'];
|
||||
const known = ['kalender', 'personen', 'abwesenheiten', 'anfragen', 'einstellungen'];
|
||||
if (!known.includes(name)) name = 'kalender';
|
||||
if (!isEditor() && EDITOR_VIEWS.includes(name)) name = 'kalender';
|
||||
if (name === 'abwesenheiten' && state.data) prepareAbsenceImport();
|
||||
@@ -170,10 +175,12 @@ function renderCalendar() {
|
||||
if (cur.getDay() === 0 || cur.getDay() === 6) continue;
|
||||
const date = iso(cur);
|
||||
const outside = cur.getMonth() !== d.getMonth();
|
||||
const editable = !outside && isEditor();
|
||||
const assigned = assignments[date] && !assignments[date].off && !holidayFor(date) && (assignments[date].main || assignments[date].co);
|
||||
const editable = !outside && (isEditor() || Boolean(assigned));
|
||||
const cell = document.createElement(editable ? 'button' : 'div');
|
||||
cell.className = 'day';
|
||||
if (editable) { cell.type = 'button'; cell.dataset.date = date; }
|
||||
if (!isEditor() && assigned) cell.classList.add('requestable');
|
||||
if (outside) cell.classList.add('outside');
|
||||
if (date === today) cell.classList.add('today');
|
||||
if (date < today) cell.classList.add('past');
|
||||
@@ -183,6 +190,8 @@ function renderCalendar() {
|
||||
numText.textContent = cur.getDate();
|
||||
num.appendChild(numText);
|
||||
cell.appendChild(num);
|
||||
const hasRequest = !outside && (state.data.requestDates || []).includes(date);
|
||||
if (hasRequest) { const m = document.createElement('span'); m.className = 'req-mark'; m.title = 'Tauschanfrage offen'; num.appendChild(m); }
|
||||
if (outside) { grid.appendChild(cell); continue; }
|
||||
|
||||
const hol = holidayFor(date);
|
||||
@@ -212,7 +221,7 @@ function renderCalendar() {
|
||||
pair.appendChild(slotEl(co, 'co', date));
|
||||
cell.appendChild(pair);
|
||||
const absentNote = (p) => (p && isAbsent(p.id, date) ? ' (abwesend)' : '');
|
||||
cell.setAttribute('aria-label', `${fmtLong(date)}: Haupt ${fullName(main)}${absentNote(main)}, Neben ${fullName(co)}${absentNote(co)}${a.locked ? ', fixiert' : ''}`);
|
||||
cell.setAttribute('aria-label', `${fmtLong(date)}: Haupt ${fullName(main)}${absentNote(main)}, Neben ${fullName(co)}${absentNote(co)}${a.locked ? ', fixiert' : ''}${hasRequest ? ', Tauschanfrage offen' : ''}`);
|
||||
} else {
|
||||
cell.setAttribute('aria-label', `${fmtLong(date)}, noch nicht ausgelost`);
|
||||
}
|
||||
@@ -288,9 +297,128 @@ $('#next-month').addEventListener('click', () => { state.month = new Date(state.
|
||||
$('#today-month').addEventListener('click', () => { state.month = startOfMonth(new Date()); renderCalendar(); });
|
||||
$('#cal-grid').addEventListener('click', (e) => {
|
||||
const cell = e.target.closest('.day[data-date]');
|
||||
if (cell) openDayDialog(cell.dataset.date);
|
||||
if (!cell) return;
|
||||
if (isEditor()) openDayDialog(cell.dataset.date); else openRequestDialog(cell.dataset.date);
|
||||
});
|
||||
|
||||
// ---------- Tauschanfrage (für alle) ----------
|
||||
const requestDialog = $('#request-dialog');
|
||||
const requestForm = $('#request-form');
|
||||
function fillRequestPersons(date) {
|
||||
const f = requestForm.elements;
|
||||
const a = state.data.assignments[date] || {};
|
||||
const active = state.data.persons.filter((p) => p.active !== false && (p.canMain || p.canCo)).sort((x, y) => fullName(x).localeCompare(fullName(y)));
|
||||
const assigned = [a.main, a.co].map(personById).filter(Boolean);
|
||||
f.personId.textContent = '';
|
||||
f.personId.appendChild(new Option('– bitte wählen –', ''));
|
||||
if (assigned.length) {
|
||||
const g = document.createElement('optgroup');
|
||||
g.label = 'An diesem Tag eingeteilt';
|
||||
for (const p of assigned) g.appendChild(new Option(fullName(p), p.id));
|
||||
f.personId.appendChild(g);
|
||||
}
|
||||
const g2 = document.createElement('optgroup');
|
||||
g2.label = 'Alle';
|
||||
for (const p of active) g2.appendChild(new Option(fullName(p), p.id));
|
||||
f.personId.appendChild(g2);
|
||||
const current = f.partnerId.value;
|
||||
f.partnerId.textContent = '';
|
||||
f.partnerId.appendChild(new Option('– noch offen –', ''));
|
||||
for (const p of active) f.partnerId.appendChild(new Option(fullName(p), p.id));
|
||||
f.partnerId.value = current || '';
|
||||
$('#request-info').textContent = assigned.length
|
||||
? `${fmtLong(date)}: ${assigned.map((p, i) => `${i === 0 && a.main === p.id ? 'Haupt' : 'Neben'} ${fullName(p)}`).join(', ')}`
|
||||
: `${fmtLong(date)}: kein Meeting geplant`;
|
||||
}
|
||||
function openRequestDialog(date) {
|
||||
const f = requestForm.elements;
|
||||
requestForm.reset();
|
||||
f.date.value = date || '';
|
||||
fillRequestPersons(f.date.value);
|
||||
$('#request-result').hidden = true;
|
||||
$('#request-submit').disabled = false;
|
||||
requestDialog.showModal();
|
||||
}
|
||||
$('#open-request').addEventListener('click', () => openRequestDialog(''));
|
||||
$('#request-close').addEventListener('click', () => requestDialog.close());
|
||||
requestForm.elements.date.addEventListener('change', () => fillRequestPersons(requestForm.elements.date.value));
|
||||
requestForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const f = requestForm.elements;
|
||||
const out = $('#request-result');
|
||||
$('#request-submit').disabled = true;
|
||||
try {
|
||||
await api('api/requests', { method: 'POST', body: { personId: f.personId.value, date: f.date.value, partnerId: f.partnerId.value || null, partnerDate: f.partnerDate.value || null, note: f.note.value } });
|
||||
out.className = 'result';
|
||||
out.textContent = 'Danke, die Anfrage ist bei der Planung angekommen. Der Tag ist im Kalender markiert, bis sie bearbeitet ist.';
|
||||
out.hidden = false;
|
||||
$('#request-close').textContent = 'Schließen';
|
||||
await load();
|
||||
} catch (err) {
|
||||
out.className = 'result error';
|
||||
out.textContent = err.message;
|
||||
out.hidden = false;
|
||||
$('#request-submit').disabled = false;
|
||||
}
|
||||
});
|
||||
requestDialog.addEventListener('close', () => { $('#request-close').textContent = 'Abbrechen'; });
|
||||
|
||||
// ---------- Anfragen bearbeiten (nur Bearbeiter) ----------
|
||||
function requestRow(r) {
|
||||
const li = document.createElement('li');
|
||||
const p = personById(r.personId);
|
||||
li.appendChild(avatarEl(p, 'plain small'));
|
||||
const grow = document.createElement('div');
|
||||
grow.className = 'grow';
|
||||
const strong = document.createElement('strong');
|
||||
strong.textContent = `${fullName(p)} kann am ${fmtDate(r.date)} nicht (${r.role === 'main' ? 'Haupt' : 'Neben'}moderation)`;
|
||||
grow.appendChild(strong);
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'request-meta';
|
||||
const partner = r.partnerId ? personById(r.partnerId) : null;
|
||||
const parts = [];
|
||||
if (partner) parts.push(`Wunsch: Tausch mit ${fullName(partner)}${r.partnerDate ? ` am ${fmtDate(r.partnerDate)}` : ' (übernimmt den Tag)'}`);
|
||||
else parts.push('Kein Wunschpartner genannt');
|
||||
parts.push(`gestellt am ${new Date(r.createdAt).toLocaleDateString('de-DE')}`);
|
||||
if (r.status !== 'offen') parts.push(r.status === 'erledigt' ? 'erledigt' : 'abgelehnt');
|
||||
meta.textContent = parts.join(' · ');
|
||||
grow.appendChild(meta);
|
||||
if (r.note) { const n = document.createElement('div'); n.className = 'request-note'; n.textContent = `„${r.note}"`; grow.appendChild(n); }
|
||||
li.appendChild(grow);
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'request-actions';
|
||||
const btn = (label, cls, fn) => { const b = document.createElement('button'); b.type = 'button'; b.className = cls; b.textContent = label; b.addEventListener('click', fn); actions.appendChild(b); };
|
||||
if (r.status === 'offen') {
|
||||
if (r.partnerId) btn('Ausführen', 'primary', () => run(async () => {
|
||||
const res = await api(`api/requests/${r.id}/execute`, { method: 'POST' });
|
||||
return res;
|
||||
}, 'Tausch ausgeführt. Nextcloud danach neu schreiben, damit die Termine stimmen.'));
|
||||
btn('Tag öffnen', '', () => { location.hash = '#kalender'; state.month = new Date(r.date + 'T12:00:00'); renderCalendar(); openDayDialog(r.date); });
|
||||
btn('Erledigt', 'ghost', () => run(() => api(`api/requests/${r.id}`, { method: 'PUT', body: { status: 'erledigt' } }), 'Als erledigt markiert.'));
|
||||
btn('Ablehnen', 'ghost', () => run(() => api(`api/requests/${r.id}`, { method: 'PUT', body: { status: 'abgelehnt' } }), 'Anfrage abgelehnt.'));
|
||||
} else {
|
||||
btn('Wieder öffnen', 'ghost', () => run(() => api(`api/requests/${r.id}`, { method: 'PUT', body: { status: 'offen' } })));
|
||||
btn('Löschen', 'ghost', () => run(() => api(`api/requests/${r.id}`, { method: 'DELETE' })));
|
||||
}
|
||||
li.appendChild(actions);
|
||||
return li;
|
||||
}
|
||||
function renderRequests() {
|
||||
const all = state.data.requests || [];
|
||||
const open = all.filter((r) => r.status === 'offen').sort((a, b) => a.date.localeCompare(b.date));
|
||||
const done = all.filter((r) => r.status !== 'offen').sort((a, b) => (b.resolvedAt || '').localeCompare(a.resolvedAt || ''));
|
||||
const list = $('#request-list');
|
||||
list.textContent = '';
|
||||
if (open.length === 0) list.innerHTML = '<li class="hint">Keine offenen Anfragen.</li>';
|
||||
for (const r of open) list.appendChild(requestRow(r));
|
||||
const doneList = $('#request-list-done');
|
||||
doneList.textContent = '';
|
||||
for (const r of done) doneList.appendChild(requestRow(r));
|
||||
doneList.hidden = !$('#requests-show-done').checked || done.length === 0;
|
||||
$('#requests-toggle').hidden = done.length === 0;
|
||||
}
|
||||
$('#requests-show-done').addEventListener('change', renderRequests);
|
||||
|
||||
// ---------- Tag-Dialog ----------
|
||||
const dayDialog = $('#day-dialog');
|
||||
const dayForm = $('#day-form');
|
||||
@@ -1014,6 +1142,7 @@ function renderAll() {
|
||||
renderPersons();
|
||||
renderPhotoField();
|
||||
renderAbsences();
|
||||
renderRequests();
|
||||
renderSettings();
|
||||
if (!$('#view-abwesenheiten').hidden) prepareAbsenceImport();
|
||||
} else {
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<a href="#kalender" data-view="kalender">Kalender</a>
|
||||
<a href="#personen" data-view="personen">Personen</a>
|
||||
<a href="#abwesenheiten" data-view="abwesenheiten">Abwesenheiten</a>
|
||||
<a href="#anfragen" data-view="anfragen">Anfragen <span id="requests-count" class="count" hidden></span></a>
|
||||
<a href="#einstellungen" data-view="einstellungen">Einstellungen</a>
|
||||
</nav>
|
||||
<button type="button" id="auth-button" class="topbar-action" hidden>Anmelden</button>
|
||||
@@ -32,6 +33,7 @@
|
||||
<button type="button" id="today-month" class="ghost">Heute</button>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="button" id="open-request" class="primary" hidden>Tausch anfragen …</button>
|
||||
<button type="button" id="open-plan" class="primary">Auslosen …</button>
|
||||
<button type="button" id="open-nextcloud">Nextcloud …</button>
|
||||
<a id="download-ics" class="button ghost" href="#" download>ICS-Datei</a>
|
||||
@@ -49,6 +51,7 @@
|
||||
<span><span class="badge co">N</span> Nebenmoderation</span>
|
||||
<span><span class="lock" aria-hidden="true"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="11" width="14" height="10" rx="1.5"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg></span> fixiert</span>
|
||||
<span><span class="avatar empty small" aria-hidden="true">?</span> unbesetzt</span>
|
||||
<span><span class="req-mark" aria-hidden="true"></span> Tauschanfrage offen</span>
|
||||
</div>
|
||||
<section class="stats" aria-labelledby="stats-title">
|
||||
<h3 id="stats-title">Einsätze in diesem Monat</h3>
|
||||
@@ -145,6 +148,15 @@
|
||||
<ul id="absence-list" class="list"></ul>
|
||||
</section>
|
||||
|
||||
<!-- Anfragen (nur Bearbeiter) -->
|
||||
<section id="view-anfragen" class="view" hidden aria-labelledby="requests-title">
|
||||
<h2 id="requests-title">Tauschanfragen</h2>
|
||||
<p class="hint">Teammitglieder melden hier, wenn sie an einem Tag nicht können. Nichts wird automatisch geändert: „Ausführen" tauscht wie gewünscht, „Tag öffnen" lässt dich selbst wählen.</p>
|
||||
<ul id="request-list" class="list"></ul>
|
||||
<label class="inline" id="requests-toggle"><input type="checkbox" id="requests-show-done"> Erledigte und abgelehnte anzeigen</label>
|
||||
<ul id="request-list-done" class="list" hidden></ul>
|
||||
</section>
|
||||
|
||||
<!-- Einstellungen -->
|
||||
<section id="view-einstellungen" class="view" hidden aria-labelledby="settings-title">
|
||||
<h2 id="settings-title">Einstellungen</h2>
|
||||
@@ -256,6 +268,25 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Dialog: Tausch anfragen (für alle) -->
|
||||
<dialog id="request-dialog">
|
||||
<form method="dialog" class="dialog-form" id="request-form">
|
||||
<h2>Tausch anfragen</h2>
|
||||
<p id="request-info" class="hint"></p>
|
||||
<label>Ich bin <select name="personId" required></select></label>
|
||||
<label>Tag, an dem ich nicht kann <input type="date" name="date" required></label>
|
||||
<label>Ich würde gern tauschen mit (optional) <select name="partnerId"><option value="">– noch offen –</option></select></label>
|
||||
<label>Wunschtag für den Tausch (optional) <input type="date" name="partnerDate"></label>
|
||||
<label>Notiz (optional) <input name="note" maxlength="300" placeholder="z. B. Arzttermin, bin erst ab 10 Uhr da"></label>
|
||||
<p id="request-result" class="result" hidden></p>
|
||||
<div class="form-actions">
|
||||
<span class="spacer"></span>
|
||||
<button type="button" class="ghost" id="request-close">Abbrechen</button>
|
||||
<button type="submit" class="primary" id="request-submit">Anfrage senden</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Dialog: Anmelden zum Bearbeiten -->
|
||||
<dialog id="login-dialog">
|
||||
<form method="dialog" class="dialog-form" id="login-form">
|
||||
|
||||
@@ -67,6 +67,15 @@ svg.icon { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-w
|
||||
.topbar nav a:hover { color: var(--white); }
|
||||
.topbar nav a[aria-current="page"] { color: var(--white); border-bottom-color: var(--white); }
|
||||
.topbar nav a:focus-visible { outline: 2px solid var(--white); outline-offset: -4px; }
|
||||
.topbar nav .count { display: inline-flex; align-items: center; justify-content: center; min-width: 18px; height: 18px; padding: 0 5px; margin-left: 6px; border-radius: 9px; background: var(--white); color: var(--tradeo-blue); font-size: 11px; font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||
.req-mark { display: inline-block; width: 10px; height: 10px; border-radius: 50%; background: var(--tradeo-blue); box-shadow: 0 0 0 2px var(--white), 0 0 0 3px var(--tradeo-blue); }
|
||||
.day .num .req-mark { margin-left: auto; margin-right: 2px; }
|
||||
.day .num .lock + .req-mark, .day .num .req-mark + .lock { margin-left: 8px; }
|
||||
.request-meta { color: var(--text-2); font-size: 13px; }
|
||||
.request-note { margin-top: 4px; font-size: 14px; }
|
||||
.request-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.viewer .day.requestable { cursor: pointer; }
|
||||
.viewer .day.requestable:hover:not(.outside) { background: var(--tint-1); }
|
||||
.topbar-action { margin-left: auto; background: transparent; color: var(--white); border-color: rgba(255, 255, 255, .4); min-height: 34px; padding: 0 12px; font-size: 13px; }
|
||||
.topbar-action:hover { background: rgba(255, 255, 255, .1); }
|
||||
.topbar-action:focus-visible { outline: 2px solid var(--white); outline-offset: 2px; }
|
||||
|
||||
@@ -6,6 +6,7 @@ import { loadEnvFile } from './env.js';
|
||||
import { createStore, imageMime } from './store.js';
|
||||
import { looksLikePlaceholder } from './image.js';
|
||||
import { createAuth, readCookie, cookieHeader } from './auth.js';
|
||||
import { validateRequest, applySwap, REQUEST_STATUS } from './requests.js';
|
||||
import { workingDays, generateSchedule, isValidDate, countAssignments, addDays } from './scheduler.js';
|
||||
import { germanHolidays, BUNDESLAENDER } from './holidays.js';
|
||||
import { buildEventLines, buildCalendar, eventUid } from './ics.js';
|
||||
@@ -59,6 +60,7 @@ app.use((req, res, next) => {
|
||||
const needsEditor = (req) => {
|
||||
if (!req.path.startsWith('/api/')) return false;
|
||||
if (req.path.startsWith('/api/auth')) return false;
|
||||
if (req.method === 'POST' && req.path === '/api/requests') return false; // Tauschanfragen dürfen alle stellen
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') return true;
|
||||
return req.path.startsWith('/api/nextcloud/') || req.path.startsWith('/api/persons/placeholder');
|
||||
};
|
||||
@@ -134,6 +136,7 @@ app.get('/api/state', (req, res) => {
|
||||
absences: [],
|
||||
holidays: st.holidays,
|
||||
assignments: st.assignments,
|
||||
requestDates: [...new Set(st.requests.filter((r) => r.status === 'offen').map((r) => r.date))],
|
||||
exported: {},
|
||||
settings: { meetingTime: st.settings.meetingTime, durationMinutes: st.settings.durationMinutes },
|
||||
bundeslaender: BUNDESLAENDER,
|
||||
@@ -147,6 +150,8 @@ app.get('/api/state', (req, res) => {
|
||||
absences: st.absences,
|
||||
holidays: st.holidays,
|
||||
assignments: st.assignments,
|
||||
requests: st.requests,
|
||||
requestDates: [...new Set(st.requests.filter((r) => r.status === 'offen').map((r) => r.date))],
|
||||
exported: st.exported,
|
||||
settings: st.settings,
|
||||
bundeslaender: BUNDESLAENDER,
|
||||
@@ -452,6 +457,64 @@ app.get('/api/stats', (req, res) => {
|
||||
res.json(countAssignments(S().assignments, r.from, r.to));
|
||||
});
|
||||
|
||||
// ---------- Tauschanfragen ----------
|
||||
const requestHits = new Map(); // ip → Zeitpunkte der letzten Anfragen (Bremse: 10 pro Stunde)
|
||||
app.post('/api/requests', (req, res) => {
|
||||
const st = S();
|
||||
const ip = clientIp(req);
|
||||
const now = Date.now();
|
||||
const hits = (requestHits.get(ip) || []).filter((t) => now - t < 3_600_000);
|
||||
if (hits.length >= 10) return res.status(429).json({ error: 'Zu viele Anfragen in kurzer Zeit. Bitte später noch einmal.' });
|
||||
if (st.requests.filter((r) => r.status === 'offen').length >= 200) return res.status(409).json({ error: 'Zu viele offene Anfragen. Bitte die Planung ansprechen.' });
|
||||
const r = validateRequest(req.body, st.persons, st.assignments);
|
||||
if (r.error) return bad(res, r.error);
|
||||
const duplicate = st.requests.find((x) => x.status === 'offen' && x.personId === r.request.personId && x.date === r.request.date);
|
||||
if (duplicate) return res.status(409).json({ error: 'Für diesen Tag liegt von dir schon eine offene Anfrage vor.' });
|
||||
const request = { id: randomUUID(), ...r.request, status: 'offen', createdAt: new Date().toISOString(), resolvedAt: null };
|
||||
st.requests.push(request);
|
||||
hits.push(now);
|
||||
requestHits.set(ip, hits);
|
||||
store.save();
|
||||
res.status(201).json({ id: request.id, status: request.status });
|
||||
});
|
||||
|
||||
app.put('/api/requests/:id', (req, res) => {
|
||||
const st = S();
|
||||
const r = st.requests.find((x) => x.id === req.params.id);
|
||||
if (!r) return res.status(404).json({ error: 'Anfrage nicht gefunden.' });
|
||||
const status = String(req.body?.status || '');
|
||||
if (!REQUEST_STATUS.includes(status)) return bad(res, 'Unbekannter Status.');
|
||||
r.status = status;
|
||||
r.resolvedAt = status === 'offen' ? null : new Date().toISOString();
|
||||
store.save();
|
||||
res.json(r);
|
||||
});
|
||||
|
||||
app.post('/api/requests/:id/execute', (req, res) => {
|
||||
const st = S();
|
||||
const r = st.requests.find((x) => x.id === req.params.id);
|
||||
if (!r) return res.status(404).json({ error: 'Anfrage nicht gefunden.' });
|
||||
if (r.status !== 'offen') return bad(res, 'Diese Anfrage ist schon abgeschlossen.');
|
||||
try {
|
||||
const { changes } = applySwap(st.assignments, r);
|
||||
r.status = 'erledigt';
|
||||
r.resolvedAt = new Date().toISOString();
|
||||
store.save();
|
||||
res.json({ request: r, changes, assignments: st.assignments });
|
||||
} catch (err) {
|
||||
bad(res, err.message);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/requests/:id', (req, res) => {
|
||||
const st = S();
|
||||
const before = st.requests.length;
|
||||
st.requests = st.requests.filter((x) => x.id !== req.params.id);
|
||||
if (st.requests.length === before) return res.status(404).json({ error: 'Anfrage nicht gefunden.' });
|
||||
store.save();
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
// ---------- Export ----------
|
||||
function eventsForRange(from, to) {
|
||||
const st = S();
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// Tauschanfragen: Betrachter melden, dass sie an einem Tag nicht können; Bearbeiter führen den Tausch aus.
|
||||
import { isValidDate } from './scheduler.js';
|
||||
|
||||
export const REQUEST_STATUS = ['offen', 'erledigt', 'abgelehnt'];
|
||||
|
||||
/** Rolle einer Person an einem Tag: 'main', 'co' oder null. */
|
||||
export function roleOn(assignment, personId) {
|
||||
if (!assignment || assignment.off || !personId) return null;
|
||||
if (assignment.main === personId) return 'main';
|
||||
if (assignment.co === personId) return 'co';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eingabe einer Anfrage prüfen. persons: [{id, active}], assignments: { date: {main, co, off} }.
|
||||
* Liefert { error } oder { request } (ohne id/status/createdAt).
|
||||
*/
|
||||
export function validateRequest(body, persons, assignments) {
|
||||
const b = body || {};
|
||||
const active = (id) => persons.find((p) => p.id === id && p.active !== false);
|
||||
const personId = String(b.personId || '');
|
||||
if (!active(personId)) return { error: 'Bitte wähle deinen Namen.' };
|
||||
const date = String(b.date || '');
|
||||
if (!isValidDate(date)) return { error: 'Ungültiger Tag.' };
|
||||
const role = roleOn(assignments[date], personId);
|
||||
if (!role) return { error: 'An diesem Tag bist du laut Plan nicht eingeteilt.' };
|
||||
const partnerId = b.partnerId ? String(b.partnerId) : null;
|
||||
if (partnerId && !active(partnerId)) return { error: 'Tauschpartner nicht gefunden.' };
|
||||
if (partnerId === personId) return { error: 'Mit dir selbst kannst du nicht tauschen.' };
|
||||
const partnerDate = b.partnerDate ? String(b.partnerDate) : null;
|
||||
if (partnerDate && !isValidDate(partnerDate)) return { error: 'Ungültiger Wunschtag.' };
|
||||
if (partnerDate && !partnerId) return { error: 'Zum Wunschtag bitte auch die Person angeben, mit der du tauschen möchtest.' };
|
||||
if (partnerDate === date) return { error: 'Der Wunschtag ist derselbe Tag.' };
|
||||
const note = String(b.note || '').trim().slice(0, 300);
|
||||
return { request: { personId, date, role, partnerId, partnerDate, note } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Tausch ausführen. Liefert { changes: [{date, role, from, to}] } und verändert assignments direkt.
|
||||
* Fälle:
|
||||
* - Partner + Wunschtag: über Kreuz – Partner übernimmt den Tag des Anfragenden, dieser den Slot des Partners am Wunschtag.
|
||||
* - Nur Partner: Partner übernimmt den Slot des Anfragenden.
|
||||
* - Ohne Partner: nicht automatisch ausführbar.
|
||||
*/
|
||||
export function applySwap(assignments, req) {
|
||||
const a = assignments[req.date];
|
||||
const role = roleOn(a, req.personId);
|
||||
if (!role) throw new Error('Die Person ist an diesem Tag nicht mehr eingeteilt. Bitte den Tag von Hand anpassen.');
|
||||
if (!req.partnerId) throw new Error('Ohne Wunschpartner bitte den Tag öffnen und selbst jemanden auswählen.');
|
||||
const otherRole = role === 'main' ? 'co' : 'main';
|
||||
if (a[otherRole] === req.partnerId) throw new Error('Der Wunschpartner ist an diesem Tag schon die andere Moderation. Bitte den Tag von Hand anpassen.');
|
||||
const changes = [];
|
||||
if (req.partnerDate) {
|
||||
const b = assignments[req.partnerDate];
|
||||
const partnerRole = roleOn(b, req.partnerId);
|
||||
if (!partnerRole) throw new Error('Der Wunschpartner ist am Wunschtag nicht eingeteilt. Bitte den Tag von Hand anpassen.');
|
||||
const otherB = partnerRole === 'main' ? 'co' : 'main';
|
||||
if (b[otherB] === req.personId) throw new Error('Die anfragende Person ist am Wunschtag schon die andere Moderation. Bitte den Tag von Hand anpassen.');
|
||||
b[partnerRole] = req.personId;
|
||||
changes.push({ date: req.partnerDate, role: partnerRole, from: req.partnerId, to: req.personId });
|
||||
}
|
||||
a[role] = req.partnerId;
|
||||
changes.push({ date: req.date, role, from: req.personId, to: req.partnerId });
|
||||
return { changes };
|
||||
}
|
||||
@@ -6,6 +6,7 @@ const DEFAULT_STATE = () => ({
|
||||
absences: [],
|
||||
holidays: [],
|
||||
assignments: {},
|
||||
requests: [],
|
||||
exported: {},
|
||||
settings: {
|
||||
meetingTime: '09:30',
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { validateRequest, applySwap, roleOn } from '../server/requests.js';
|
||||
|
||||
const persons = [{ id: 'a', active: true }, { id: 'b', active: true }, { id: 'c', active: true }, { id: 'x', active: false }];
|
||||
const plan = () => ({
|
||||
'2026-10-05': { main: 'a', co: 'b', locked: false },
|
||||
'2026-10-06': { main: 'c', co: 'a', locked: false },
|
||||
'2026-10-07': { main: 'b', co: 'c', locked: false },
|
||||
'2026-10-08': { off: true, main: null, co: null },
|
||||
});
|
||||
|
||||
test('roleOn', () => {
|
||||
const p = plan();
|
||||
assert.equal(roleOn(p['2026-10-05'], 'a'), 'main');
|
||||
assert.equal(roleOn(p['2026-10-05'], 'b'), 'co');
|
||||
assert.equal(roleOn(p['2026-10-05'], 'c'), null);
|
||||
assert.equal(roleOn(p['2026-10-08'], 'a'), null);
|
||||
assert.equal(roleOn(undefined, 'a'), null);
|
||||
});
|
||||
|
||||
test('validateRequest prüft Person, Tag, Einteilung und Partner', () => {
|
||||
const p = plan();
|
||||
assert.match(validateRequest({}, persons, p).error, /Namen/);
|
||||
assert.match(validateRequest({ personId: 'x', date: '2026-10-05' }, persons, p).error, /Namen/);
|
||||
assert.match(validateRequest({ personId: 'a', date: 'gestern' }, persons, p).error, /Ungültiger Tag/);
|
||||
assert.match(validateRequest({ personId: 'c', date: '2026-10-05' }, persons, p).error, /nicht eingeteilt/);
|
||||
assert.match(validateRequest({ personId: 'a', date: '2026-10-05', partnerId: 'a' }, persons, p).error, /selbst/);
|
||||
assert.match(validateRequest({ personId: 'a', date: '2026-10-05', partnerDate: '2026-10-07' }, persons, p).error, /Person angeben/);
|
||||
assert.match(validateRequest({ personId: 'a', date: '2026-10-05', partnerId: 'b', partnerDate: '2026-10-05' }, persons, p).error, /derselbe/);
|
||||
const ok = validateRequest({ personId: 'a', date: '2026-10-05', partnerId: 'c', partnerDate: '2026-10-07', note: ' Zahnarzt ' }, persons, p);
|
||||
assert.deepEqual(ok.request, { personId: 'a', date: '2026-10-05', role: 'main', partnerId: 'c', partnerDate: '2026-10-07', note: 'Zahnarzt' });
|
||||
const minimal = validateRequest({ personId: 'b', date: '2026-10-05' }, persons, p);
|
||||
assert.deepEqual(minimal.request, { personId: 'b', date: '2026-10-05', role: 'co', partnerId: null, partnerDate: null, note: '' });
|
||||
});
|
||||
|
||||
test('applySwap über Kreuz', () => {
|
||||
const p = plan();
|
||||
const { changes } = applySwap(p, { personId: 'a', date: '2026-10-05', partnerId: 'c', partnerDate: '2026-10-07' });
|
||||
assert.equal(p['2026-10-05'].main, 'c');
|
||||
assert.equal(p['2026-10-07'].co, 'a');
|
||||
assert.equal(changes.length, 2);
|
||||
});
|
||||
|
||||
test('applySwap: Partner übernimmt den Tag', () => {
|
||||
const p = plan();
|
||||
applySwap(p, { personId: 'b', date: '2026-10-05', partnerId: 'c', partnerDate: null });
|
||||
assert.deepEqual([p['2026-10-05'].main, p['2026-10-05'].co], ['a', 'c']);
|
||||
});
|
||||
|
||||
test('applySwap lehnt unmögliche Fälle verständlich ab', () => {
|
||||
assert.throws(() => applySwap(plan(), { personId: 'a', date: '2026-10-05', partnerId: null }), /Wunschpartner/);
|
||||
assert.throws(() => applySwap(plan(), { personId: 'c', date: '2026-10-05', partnerId: 'b' }), /nicht mehr eingeteilt/);
|
||||
assert.throws(() => applySwap(plan(), { personId: 'a', date: '2026-10-05', partnerId: 'b' }), /andere Moderation/);
|
||||
assert.throws(() => applySwap(plan(), { personId: 'a', date: '2026-10-05', partnerId: 'c', partnerDate: '2026-10-08' }), /am Wunschtag nicht eingeteilt/);
|
||||
assert.throws(() => applySwap(plan(), { personId: 'a', date: '2026-10-05', partnerId: 'c', partnerDate: '2026-10-06' }), /anfragende Person/);
|
||||
});
|
||||
Reference in new issue
Block a user