Compare commits

...
16 Commits
Author SHA1 Message Date
ClaudeandClaude Opus 5.5 4e8b8f1698 Projektarten: Schema-Skript liest alle Attribute (limit 200)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:33:21 +00:00
ClaudeandClaude Opus 5.5 43c168ab54 Projektarten: Zuordnungs-Mail heisst T17 (T16 ist Funktion bestellt)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:32:12 +00:00
ClaudeandClaude Opus 5.5 c1f8ace348 Test: Arbeitsblatt-Reihenfolge unabhaengig von der Uhrzeit
Die 55 Fuellblaetter hatten keinen festen Zeitstempel und waren ab
30.09.2026 18:00 UTC neuer als ws1 - der Test schlug ab dann fehl.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:32:12 +00:00
ClaudeandClaude Sonnet 5.5 d3c6858ecc Projektarten: Zuordnungs-Mail auch ohne vorherigen Versuch nachholen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 18:31:20 +00:00
ClaudeandClaude Sonnet 5.5 1f1ffb5275 Projektarten: neue Repos erkennen und Zuordnungs-Mail senden
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 18:31:20 +00:00
ClaudeandClaude Sonnet 5.5 de4b6f3a35 Projektarten: T16 Versuchszähler je Mail (R7)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 18:31:11 +00:00
ClaudeandClaude Sonnet 5.5 b8a530958f Projektarten: Mail T16 Repo zuordnen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 18:31:07 +00:00
ClaudeandClaude Sonnet 5.5 08958dee77 Projektarten: Services-Uebersicht fuer WOMS
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 18:30:53 +00:00
ClaudeandClaude Sonnet 5.5 3e6f35f836 Projektarten: Zuordnen gibt Warnungen von assignProject weiter
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 18:30:53 +00:00
ClaudeandClaude Sonnet 5.5 36c7822783 Projektarten: Zuordnen-Ablauf serverseitig
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 18:30:53 +00:00
ClaudeandClaude Sonnet 5.5 df0c7a05e8 Tickets: Startdatum als TT.MM.JJJJ, Frist gleich (R5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 18:30:53 +00:00
ClaudeandClaude Sonnet 5.5 c93cb93a36 Tickets: createTicketForProject und deleteTicket
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 18:30:53 +00:00
ClaudeandClaude Sonnet 5.5 85b972f9dc Projektarten: Schema-Skript fuer websiteProjects
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 18:25:28 +00:00
ClaudeandClaude Sonnet 5.5 a2e308711e Projektarten: Katalog
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 18:25:27 +00:00
ClaudeandClaude Opus 5.5 b98562bb4f Plan: Projektarten, Zuordnungs-Ablauf und Services
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:25:27 +00:00
ClaudeandClaude Opus 5.5 7a4af8e3c8 Spec: Projektarten, Zuordnungs-Ablauf und Bereich Services
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:25:27 +00:00
25 changed files with 1865 additions and 6 deletions

No files matched your search

@@ -0,0 +1,220 @@
# Projektarten, Zuordnungs-Ablauf und „Services“ – Umsetzungsplan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Jedes Repo bekommt eine Art; neue Repos (Gitea oder WOMS) werden per Mail bzw. direkt durch einen
Ablauf geführt, bis Art, Kunde und Ticket feststehen; WOMS bekommt den Bereich „Services“.
**Architecture:** Portal (Express, `node --test`) hält Katalog, Schema, Zuordnungs-API, Mail und Erkennung
(Webhook-Ereignis `repository` + 10-Minuten-Job). WOMS (React/Vite, `node --test tests/unit/*.test.mjs`)
bekommt die Seiten `/projects/zuordnen/:id` und `/services` und spricht nur über die Portal-Admin-API.
**Tech Stack:** Node 20/24, Express, Appwrite (node-appwrite über `server/services/appwriteAdmin.js`),
nodemailer + HTML-Vorlagen (`server/mail-templates/`), React 18 + react-router, Playwright (Fixture-Check).
**Spec:** `docs/superpowers/specs/2026-09-30-projektarten-services-design.md` (Commit 7b489fa)
**Worktrees:** Portal `/home/webklar/apps/kundenbereich.webklar.com/dev-projektarten` (Branch
`feature/projektarten`). WOMS: neu anlegen `git -C /home/webklar/apps/ticket.webklar.com/build worktree add
-b feature/projektarten ../dev-projektarten origin/test` (Rebase-Falle beachten: getrackte node_modules ⇒
bei Konflikten cherry-pick in temporärem Worktree).
## Global Constraints
- Arten-Schlüssel exakt: `website`, `webapp`, `app`, `migration`, `automation`, `other`; Labels exakt: „Website“, „Web-App“, „App“, „Migration/Umzug“, „Automation/Skript“, „Sonstiges“.
- Preview je Art: website `yes`, webapp `possible`, alle anderen `no`. Funktionen: nur website ⇒ `['analyse']`; `FEATURES.analyse = { label: 'Website-Analyse', status: 'in_development' }`.
- Leeres `kind` = „Art fehlt“. **Keine** automatische Einordnung bestehender Repos.
- Neues Ticket entsteht **nur** beim Abschließen; Thema „<Art-Label> – <Repo-Name>“, Ticket-Art `Migration` bei Art migration sonst `Webpage`, `status: 'Open'`, `serviceType: 'Remote'`, `systemType: 'n/a'`.
- Mail nur für Repos, deren Projekt-Dokument im aktuellen Lauf/Ereignis **neu** angelegt wurde **und** deren Gitea-`created_at` < 24 h alt ist. Eine Erinnerung nach 24 h, danach nichts. Max. 3 Versandversuche.
- Mail-Link exakt `https://ticket.webklar.com/projects/zuordnen/<projektId>`.
- Alle Texte Deutsch, Du-Form wie T15. Kein `confirm()` in der UI.
- Nichts pushen/live schalten ohne OK des Nutzers (Livegang L1–L5 unten).
## Review Focus
1. Doppel-Abschluss (zwei Tabs / zwei Mitarbeiter) ⇒ zweiter Aufruf 409, kein zweites Ticket. → Task 4
2. `assignProject` wirft nach neu angelegtem Ticket ⇒ Ticket wird gelöscht, `kind` nicht gesetzt. → Task 4
3. Erster Job-Lauf nach Livegang findet viele Alt-Repos ⇒ **keine** Mails (24-h-Grenze). → Task 6
4. Ticket gehört zu anderem Kunden oder ist Closed/Cancelled ⇒ 400. → Task 4
5. Gitea `repository`-Ereignis für ein Repo, das der Job im selben Moment importiert ⇒ genau ein Dokument, genau eine Mail. → Task 7
---
## Portal
### Task 1: Katalog `projectKinds.js`
**Files:** Create `server/services/projectKinds.js`, Test `test/projectKinds.test.js`
**Interfaces – Produces:**
- `KINDS: Array<{ key, label, preview: 'yes'|'possible'|'no', features: string[], ticketType: 'Webpage'|'Migration', description }>`
- `FEATURES: { analyse: { label, status } }`
- `isKind(key: string): boolean`, `kindLabel(key): string` ('' bei unbekannt), `ticketTypeFor(key): 'Webpage'|'Migration'`
- `servicesCatalog(): { kinds: KINDS, features: FEATURES }`
- [ ] **Step 1: Test schreiben** – `KINDS.map(k=>k.key)` deepEqual der 6 Schlüssel in Reihenfolge; Labels exakt; `isKind('website')===true`, `isKind('shop')===false`, `isKind('')===false`; `ticketTypeFor('migration')==='Migration'`, `ticketTypeFor('app')==='Webpage'`; `KINDS.find(k=>k.key==='website').features` deepEqual `['analyse']`, alle anderen `[]`; Preview-Werte wie Global Constraints.
- [ ] **Step 2:** `node --test test/projectKinds.test.js` ⇒ FAIL (Modul fehlt)
- [ ] **Step 3:** implementieren; Beschreibungen je eine Zeile („Was wir liefern“), z. B. Website „Internetauftritt inkl. Landingpage oder Shop“.
- [ ] **Step 4:** Test ⇒ PASS
- [ ] **Step 5:** `git commit -m "Projektarten: Katalog"`
### Task 2: Schema-Skript
**Files:** Create `scripts/projektarten-setup.mjs`, Test `test/projektartenSetup.test.js`
**Interfaces – Produces:** exportiertes `PROJECT_KIND_ATTRIBUTES` (Array `{ key, type, size?, required:false }`) und
`main({ check })`; Muster `scripts/setup-chat-collection.mjs` (idempotent: vorhandene Attribute überspringen, `--check` nur melden).
Attribute an `websiteProjects`: `kind` string 20, `forCustomer` boolean, `kindSetBy` string 64, `kindSetAt`
string 40, `createdByGitea` string 64, `assignMailSentAt` string 40, `assignReminderSentAt` string 40,
`assignMailAttempts` integer.
- [ ] **Step 1: Test** – die 8 Schlüssel exakt, alle `required: false`; `main` mit gemocktem Client, der `kind` schon kennt, legt nur die 7 anderen an.
- [ ] **Step 2:** FAIL · **Step 3:** implementieren · **Step 4:** PASS
- [ ] **Step 5:** `git commit -m "Projektarten: Schema-Skript fuer websiteProjects"`
### Task 3: `createTicketForProject`
**Files:** Modify `server/services/ticketAdmin.js`, Test `test/ticketAdmin.test.js` (Mock-Muster wie `test/projectAssignment.test.js` / `test/helpers`)
**Interfaces – Produces:** `createTicketForProject({ customerId, topic, type, requestedBy, startDate, details }): Promise<{ $id, woid }>`
– nutzt `generateNextWoid`, Felder wie `ensureProjectTicket` (priority 1, serviceType 'Remote', systemType 'n/a',
customerName/Location aus dem Kunden) plus `requestedBy`, `startDate`. `deleteTicket(ticketId): Promise<void>`.
- [ ] **Step 1: Test** – erzeugt Dokument mit `type`, `topic`, `status:'Open'`, `customerName` vom Kunden, nächster WOID; unbekannter Kunde ⇒ Fehler mit `status 400`; `deleteTicket` ruft `deleteDocument(workorders, id)`.
- [ ] **Step 2–4:** FAIL → implementieren → PASS
- [ ] **Step 5:** `git commit -m "Tickets: createTicketForProject und deleteTicket"`
### Task 4: Zuordnen-Service + Route
**Files:** Create `server/services/projectKindAssign.js`, Modify `server/routes/admin/websiteProjects.js`,
Test `test/projectKindAssign.test.js`
**Interfaces:**
- Consumes: Task 1 (`isKind`, `kindLabel`, `ticketTypeFor`), Task 3, `assignProject(projectId, {customerId, ticketId})`, `updateWebsiteProject(id, { subdomain, projectType })`, `withLocks`, `repoLockKey`.
- Produces: `completeAssignment(projectId, { kind, forCustomer, customerId, ticketId, newTicket, preview }, { staffName, now }): Promise<{ ticketId: string|null }>`; Route `POST /api/admin/website-projects/:id/zuordnen` ⇒ 200 `{ ticketId }`, 400 `{ error }`, 404, 409 `{ error: 'bereits zugeordnet', kindSetBy, kindSetAt }`. `staffName` aus `req.appwriteUser.name || email`.
Ablauf unter `withLocks([repoLockKey(repoFullName)])`: Projekt laden (404) → `kind` gesetzt ⇒ 409 → prüfen
(Art gültig; bei `forCustomer` Kunde existiert, genau eins von `ticketId`/`newTicket`, vorhandenes Ticket gehört
dem Kunden und Status ∉ {Closed, Cancelled}) → ggf. Ticket anlegen → `assignProject` (bei Fehler: neu angelegtes
Ticket `deleteTicket`, Fehler weiterwerfen) → `updateDocument(kind, forCustomer, kindSetBy, kindSetAt)` →
wenn `kind==='website' && forCustomer && preview?.subdomain`: `updateWebsiteProject(id, { subdomain, projectType: 'preview' })`.
Intern (`forCustomer:false`): nur Art speichern, `ticketId: null`.
- [ ] **Step 1: Tests** (je ein `test(...)`):
`intern speichert nur Art` · `vorhandenes Ticket wird zugewiesen` · `neues Ticket nur beim Abschliessen, Thema "Website – foo"` ·
`Migration ergibt Ticket-Art Migration` · `Ticket anderer Kunde ⇒ 400` · `Ticket Closed ⇒ 400` · `unbekannte Art ⇒ 400` ·
`assignProject wirft ⇒ neues Ticket geloescht, kind leer` · `zweiter Aufruf ⇒ 409, nur ein Ticket` ·
`Website mit Subdomain ⇒ updateWebsiteProject(preview)` · Route: ohne Staff-JWT ⇒ 401.
- [ ] **Step 2–4:** FAIL → implementieren → PASS (`npm test` komplett grün)
- [ ] **Step 5:** `git commit -m "Projektarten: Zuordnen-Ablauf serverseitig"`
### Task 5: `GET /api/admin/services`
**Files:** Create `server/routes/admin/services.js`, Modify `server/index.js` (`app.use('/api/admin/services', …)`), Test `test/servicesRoute.test.js`
**Interfaces – Produces:** Antwort `{ kinds, features, groups: { [kind|'missing']: Array<{ id, projectName, repoFullName, customerId, customerName, ticketId, woid, previewUrl, features: {} }> } }`.
Archivierte und `isTemplate`-Projekte ausgeschlossen. `previewUrl` = `https://<subdomain>.project.webklar.com` bzw. `liveDomain`, sonst ''. `features` bleibt `{}` (kommt mit Teilprojekt 3).
- [ ] **Step 1: Test** – 3 Projekte (website, leer, archiviert) ⇒ `groups.website.length===1`, `groups.missing.length===1`, archiviertes fehlt; Kundenname aufgelöst; `requireAppwriteStaff` aktiv.
- [ ] **Step 2–4:** FAIL → implementieren → PASS
- [ ] **Step 5:** `git commit -m "Projektarten: Services-Uebersicht fuer WOMS"`
### Task 6: Mail „Neues Repo zuordnen“ (T16) + Auswahl-Logik
**Files:** Create `server/mail-templates/T16 Repo zuordnen.html`, Create `server/services/repoAssignMail.js`,
Test `test/repoAssignMail.test.js`
**Interfaces:**
- Consumes: `findEmployeeForPusher`, `sendStaffMail`, `renderMail`, `berlinDateTime`.
- Produces: `shouldSendAssignMail(project, repo, { now, createdNow }): boolean`; `dueReminder(project, { now }): boolean`;
`sendRepoAssignMail(project, { giteaLogin, reminder }): Promise<{ sent, reason? }>` (setzt `assignMailSentAt` bzw. `assignReminderSentAt`, zählt `assignMailAttempts`).
Vorlage: Header-Kommentar wie T15 (Betreff/Platzhalter). Betreff „Neues Repo zuordnen: {{repo}}“, Erinnerung „Erinnerung: …“
(Block `<!-- WENN erinnerung -->`). Inhalt: Repo, Gitea-Link, angelegt von/am, Knopf „Jetzt zuordnen“ ⇒ Link aus Global Constraints.
- [ ] **Step 1: Tests** – `shouldSendAssignMail` false bei `createdNow:false`, false bei `created_at` 25 h alt, false bei gesetztem `kind`, false bei `assignMailAttempts>=3`, true bei neu + 1 h; `dueReminder` true genau wenn `assignMailSentAt` ≥ 24 h, `kind` leer, `assignReminderSentAt` leer; Mail enthält den exakten Link; unbekannter Login ⇒ `{ sent:false, reason:'no_employee' }`; `renderMail` meldet keine `problems`.
- [ ] **Step 2–4:** FAIL → implementieren → PASS
- [ ] **Step 5:** `git commit -m "Projektarten: Mail T16 Repo zuordnen"`
### Task 7: Erkennung – Webhook-Ereignis + 10-Minuten-Job
**Files:** Modify `server/routes/webhook/gitea.js`, Modify `server/services/giteaAdmin.js` (`importNewRepos`),
Create `server/services/repoAssignJob.js`, Modify `server/index.js`, Test `test/repoAssignDetect.test.js`
**Interfaces:**
- `importNewRepos()` liefert zusätzlich `createdRepos: Array<{ projectId, repo }>` (bestehendes `{ created }` bleibt).
- Webhook: Header `X-Gitea-Event: repository` mit `payload.action === 'created'` ⇒ unter `repoLockKey` Dokument anlegen falls fehlt (`createdByGitea = payload.sender.login`), dann `sendRepoAssignMail` falls `shouldSendAssignMail`; Antwort 200. Andere `repository`-Aktionen ⇒ 200 ignoriert. Push-Pfad unverändert.
- `runRepoAssignJob({ now })`: `importNewRepos()` ⇒ für `createdRepos` `createdByGitea = repo.owner.login` wenn Owner kein Org-Konto, Mail nach Regel; danach Erinnerungen (`dueReminder`) und Wiederholungen (Mail fehlgeschlagen, Versuche < 3). Bei Appwrite-Fehler: Lauf abbrechen, loggen. In `server/index.js` `setInterval(…, 10*60*1000).unref()` + einmal 60 s nach Start.
- [ ] **Step 1: Tests** – `repository/created` ⇒ 1 Dokument + 1 Mail; gleichzeitig laufender Job für dasselbe Repo ⇒ weiterhin 1 Dokument, 1 Mail; `repository/deleted` ⇒ nichts; Job-Lauf mit 20 alten Repos (created_at vor 3 Tagen) ⇒ 0 Mails; Org-Repo ohne Sender im Job ⇒ keine Mail; Push-Webhook-Tests (`pushTimeWebhook.test.js`) weiterhin grün.
- [ ] **Step 2–4:** FAIL → implementieren → PASS (`npm test` komplett)
- [ ] **Step 5:** `git commit -m "Projektarten: neue Repos erkennen und Zuordnungs-Mail senden"`
## WOMS
### Task 8: API-Client + Ablauf-Logik
**Files:** Create `src/lib/projectKindsApi.js` (Muster `projectAdminApi.js`), Create `src/lib/assignFlow.js`, Test `tests/unit/assignFlow.test.mjs`
**Interfaces – Produces:**
- `fetchServices()`, `completeAssignment(projectId, body)` (⇒ Task-4-Route), `fetchProject(projectId)` (vorhandene Projekt-Abfrage wiederverwenden).
- `nextStep(state): 'kind'|'customer?'|'customer'|'ticket'|'summary'` und `prevStep(state)`; intern springt von `customer?` direkt zu `summary`.
- `newTicketDefaults({ kindLabel, ticketType, repoName, repoUrl, customer, today }): { topic, type, requestedBy, startDate, details }` – `topic` "`${kindLabel} – ${repoName}`", `requestedBy` = `customer.contactName || customer.name`, `details` enthält `repoUrl`.
- `canFinish(state): boolean` (Kunde ⇒ Ticket oder vollständiges newTicket nötig).
- `openTicketsFor(tickets, customerId)`: Status ∉ {Closed, Cancelled}.
- [ ] **Step 1: Tests** für alle fünf Funktionen mit den Werten oben (u. a. „Migration/Umzug – foo“ ⇒ type 'Migration').
- [ ] **Step 2–4:** `npm test` FAIL → implementieren → PASS
- [ ] **Step 5:** `git commit -m "Projektarten: Ablauf-Logik und API-Client"`
### Task 9: Seite `/projects/zuordnen/:id`
**Files:** Create `src/pages/AssignProjectPage.jsx`, Modify `src/App.jsx` (ProtectedRoute), CSS in vorhandenem Stil.
Schritte laut Spec Abschnitt 2 mit Task-8-Logik. Kopf: Repo, Gitea-Link, angelegt von/am. „Neuer Kunde“ öffnet das
vorhandene Kunden-Formular (Modal aus `CustomersPage`) und übernimmt den neuen Kunden. Bei `kind` gesetzt: Hinweis
„Bereits erledigt von <kindSetBy> am <Datum>“, Link zum Ticket, Knopf „Art ändern“ (nur Schritt 1, dann speichern).
Nach Erfolg `navigate('/tickets?open=<ticketId>')` (vorhandenes Öffnen-Muster der TicketsPage verwenden), intern ⇒ `/services`.
409 ⇒ Seite neu laden und Hinweis zeigen.
- [ ] **Step 1:** implementieren · **Step 2:** `npm run build` ohne Fehler
- [ ] **Step 3:** `git commit -m "Projektarten: Zuordnungs-Ablauf im WOMS"`
### Task 10: Seite `/services` + Navigation
**Files:** Create `src/pages/ServicesPage.jsx`, Create `src/lib/servicesView.js`, Modify `src/App.jsx`, `src/components/Navbar.jsx` (Eintrag „Services“ nach „Projekte“), Test `tests/unit/servicesView.test.mjs`
**Interfaces – Produces:** `servicesCards(apiResponse): Array<{ key, label, description, count, features:[{label, statusLabel}], projects }>`,
Karte `missing` („Art fehlt“) zuletzt, nur wenn nicht leer; `statusLabel` für `in_development` = „In Entwicklung“.
- [ ] **Step 1: Test** `servicesCards` (Reihenfolge = Katalog, Zählung, Art-fehlt-Karte, Statuslabel).
- [ ] **Step 2–4:** FAIL → implementieren (Karte je Art, Projektliste, Knopf „Zuordnen“ ⇒ `/projects/zuordnen/:id`) → PASS; `npm run build` ok
- [ ] **Step 5:** `git commit -m "Projektarten: Bereich Services"`
### Task 11: Weitere Einstiege
**Files:** Modify `src/components/TicketProjects.jsx`, `src/pages/ProjectsPage.jsx`
- TicketProjects: beim Zuweisen eines Projekts ohne `kind` erst kleiner Dialog „Was ist das?“ (6 Knöpfe), dann
`completeAssignment(id, { kind, forCustomer:true, customerId: ticket.customerId, ticketId: ticket.$id })` statt `assignProjects`.
Projekte mit `kind` weiter wie bisher. Art je zugewiesenem Projekt als kleines Label anzeigen.
- ProjectsPage „Neues Repo anlegen“: nach erfolgreichem `create-from-template` ⇒ `navigate('/projects/zuordnen/<id>')` (ID aus der Antwort; fehlt sie im Portal-Result, in Task 4 ergänzen).
- [ ] **Step 1:** implementieren · **Step 2:** `npm test` + `npm run build` grün · **Step 3:** `git commit -m "Projektarten: Art beim Zuweisen und nach Repo-Anlage"`
### Task 12: Oberflächen-Check (Playwright, Fixture)
WOMS-`dist` statisch ausliefern + Fixture-API (Portal-Admin-Routen gemockt). Prüfen:
Ablauf intern; Kunde + vorhandenes Ticket; Kunde + neues Ticket (Vorbelegung sichtbar, Abbruch ⇒ kein POST);
409-Hinweis; Services-Seite mit Art-fehlt-Karte; Handy-Breite 390 px ohne horizontales Scrollen.
Skript in den Scratchpad, Screenshots nach `/root/reports/projektarten-2026-09-30/`.
- [ ] **Step 1:** Checks laufen grün · **Step 2:** Ergebnis im SDD-Ledger festhalten
## Livegang (jeder Schritt nur mit OK des Nutzers)
- **L1** `docker exec -w /app webklar-kundenbereich-server node scripts/projektarten-setup.mjs --check`, dann ohne `--check`.
- **L2** Portal: rebase auf origin/main, `npm test`, `git push origin feature/projektarten:main`; prüfen: Container-Start, `GET /api/admin/services` ohne JWT ⇒ 401, Preview-Sync ok.
- **L3** Org-Hook 112: Ereignisse `push` + `repository` (PATCH mit System-Token, vorher Stand sichern).
- **L4** WOMS: `git push origin feature/projektarten:test`; prüfen: Bundle enthält „Services“.
- **L5** Live-Test mit dem Nutzer: Repo in Gitea anlegen ⇒ Mail ⇒ Ablauf mit Test-Kunde/Ticket #99999 ⇒ Ticket/Preview prüfen ⇒ aufräumen (Repo, Dokument, ggf. Ticket). **Erst danach Teilprojekt 2.**
@@ -0,0 +1,143 @@
# Projektarten, Zuordnungs-Ablauf und Bereich „Services“ – Design
Stand: 2026-09-30 · Status: vom Nutzer im Chat freigegeben (Abschnitte 1–3), Spec zur Durchsicht
Gesamtvorhaben in drei Teilprojekten, **nacheinander**: jedes wird gebaut, geht live und wird vom Nutzer
live getestet, **bevor** das nächste beginnt.
1. **Projektarten + Services** (diese Spec)
2. Preview 2.0 (statisch / Build / Dev-Server bei Bedarf, Build ohne Portal-Geheimnisse) – eigene Spec
3. Funktion „Website-Analyse“ (Einwilligung, eigene Auswertung, Heatmap, Rechtstexte) – eigene Spec
Recherchen dazu: `/root/reports/analyse-funktion-2026-09-30/` (projekttypen-preview.md, technik-analyse-heatmap.md,
recht-tracking-deutschland.md).
## Ziel
Jedes Repo hat eine **Art** (Website, Web-App, …). Egal ob es in Gitea oder im WOMS angelegt wird, wird
die anlegende Person durch einen Ablauf geführt, bis Art, Kunde und Ticket feststehen. Es entstehen
**keine leeren Tickets** und **keine verwaisten Kunden-Repos**. Im WOMS zeigt ein Bereich „Services“ unsere
Leistungsarten, was je Art möglich ist und welche Projekte welche Art haben.
## Entscheidungen (vom Nutzer)
| # | Entscheidung |
|---|---|
| P1 | Arten: **Website, Web-App, App, Migration/Umzug, Automation/Skript, Sonstiges**. Landingpage und Shop laufen als Website; Wartung ist ein Abo, keine Art; Template bleibt eigene Markierung. |
| P2 | Bestehende Repos bekommen **keine automatische Art**; sie stehen auf „Art fehlt“, bis jemand sie von Hand setzt. |
| P3 | Gitea bleibt erlaubt. Wer ein Repo in Gitea anlegt, bekommt eine **Mail** (Muster Push-Zeitmail) mit Link in den Zuordnungs-Ablauf. |
| P4 | Der Ablauf gilt **auch** für den Mail-Link, **mit WOMS-Login** (inkl. 2FA). |
| P5 | Ablauf: Art → Für einen Kunden? → Kunde → Ticket (vorhandenes oder neu) → Abschließen. Ein neues Ticket entsteht **erst beim Abschließen**. |
| P6 | Bereich „Services“ = Variante **B**: Katalog + Projektlisten je Art. Funktionen werden **nur am Projekt** geschaltet. |
| P7 | Jetzt nur das Gerüst und als Funktion „Website-Analyse“ (Status *In Entwicklung*). Weitere Funktionen fragt Claude am Ende ab. |
## 1. Datenmodell und Erkennung
**`websiteProjects` – neue Attribute** (Schema-Skript `scripts/projektarten-setup.mjs`, idempotent, Muster
`portal-setup.mjs`):
- `kind` (string 20, optional): `website` | `webapp` | `app` | `migration` | `automation` | `other`; leer = Art fehlt
- `forCustomer` (boolean, optional; leer = noch nicht beantwortet)
- `kindSetBy` (string 64), `kindSetAt` (ISO-String)
- `createdByGitea` (string 64): Gitea-Login der anlegenden Person (für die Mail)
- `assignMailSentAt`, `assignReminderSentAt` (ISO-Strings)
`projectType` (preview/website/template/repo) bleibt unverändert und regelt weiter nur das Hosting.
**Katalog** `server/services/projectKinds.js` (einzige Quelle der Wahrheit):
```js
KINDS = [
{ key: 'website', label: 'Website', preview: 'yes', features: ['analyse'], description: '…' },
{ key: 'webapp', label: 'Web-App', preview: 'possible', features: [], … },
{ key: 'app', label: 'App', preview: 'no', … },
{ key: 'migration', label: 'Migration/Umzug', preview: 'no', ticketType: 'Migration', … },
{ key: 'automation', label: 'Automation/Skript', preview: 'no', … },
{ key: 'other', label: 'Sonstiges', preview: 'no', … },
]
FEATURES = { analyse: { label: 'Website-Analyse', status: 'in_development' } }
```
Ausgeliefert über `GET /api/admin/services` (Admin-JWT wie die anderen WOMS-Aufrufe). WOMS hat keine eigene Kopie.
**Erkennung neuer Repos**
1. Org-Webhook WEBklar (Hook 112) bekommt zusätzlich das Ereignis `repository` (created). Der Handler in
`server/routes/webhook/gitea.js` legt das Projekt-Dokument an (`upsertWebsiteProjectByRepo`), setzt
`createdByGitea = sender.login` und stößt die Mail an. `deleted` wird ignoriert (Verhalten wie heute).
2. Job alle 10 Minuten in `server/index.js`: `importNewRepos()` (dieselbe Funktion wie „Suche neue Repos“)
findet Repos in persönlichen Konten. `createdByGitea` = Repo-Owner (bei Org-Repos ohne Hook-Ereignis leer
⇒ keine Mail, erscheint nur in „Art fehlt“).
3. **Nur neu angelegte** Projekt-Dokumente lösen eine Mail aus (Dokument in diesem Lauf erzeugt **und**
Repo `created_at` jünger als 24 h). Beim ersten Lauf nach dem Livegang entsteht so keine Mail-Welle.
4. Empfänger = Mitarbeiter, dessen Kürzel/Anzeigename zum Gitea-Login passt (`findEmployeeForPusher` aus
`pushTimeMail.js` wiederverwenden). Kein Treffer ⇒ keine Mail, Log-Zeile.
5. Mail (neue Staff-Vorlage **T16 „Neues Repo zuordnen“**, Stil wie T15): Repo-Name, Gitea-Link, wer, wann,
Knopf „Jetzt zuordnen“ ⇒ `https://ticket.webklar.com/projects/zuordnen/<projektId>`.
6. Erinnerung: 1× nach 24 h, falls `kind` noch leer (derselbe 10-Minuten-Job). Danach nichts mehr.
## 2. Zuordnungs-Ablauf (WOMS)
Route **`/projects/zuordnen/:id`** (ProtectedRoute, also WOMS-Login + 2FA). Kopf: Repo-Name, Gitea-Link,
angelegt von/am. Schritte mit „Zurück“/„Weiter“:
1. **Was ist das?** sechs Knöpfe aus dem Katalog.
2. **Für einen Kunden?** Ja / Nein (internes Projekt ⇒ weiter zu 5).
3. **Welcher Kunde?** Suche über Kunden; „Neuer Kunde“ öffnet das vorhandene Kunden-Formular und kehrt mit
dem neuen Kunden zurück.
4. **Welches Ticket?** offene Tickets (Status ≠ Closed/Cancelled) dieses Kunden, oder „Neues Ticket anlegen“
mit vorausgefüllten, änderbaren Feldern: Thema „<Art> – <Repo-Name>“, Ticket-Art `Migration` bei Art
Migration sonst `Webpage`, Angefragt von = Ansprechpartner des Kunden, Start heute, Details = Repo-Link.
5. **Zusammenfassung + „Abschließen“.** Bei Art Website mit Kunde: Preview-Schalter an, Subdomain-Vorschlag
aus dem Repo-Namen (änderbar, Prüfung wie heute).
**Abschließen** = ein Aufruf `POST /api/admin/website-projects/:id/zuordnen`
`{ kind, forCustomer, customerId?, ticketId? | newTicket?, preview?: { subdomain } }`. Der Server:
1. prüft Eingaben (Art aus Katalog, Kunde existiert, Ticket gehört zum Kunden und ist offen);
2. legt ggf. das Ticket an (neue Funktion `createTicketForProject` in `ticketAdmin.js`, Felder wie das
WOMS-Formular);
3. ruft `assignProject(projectId, { customerId, ticketId })` (Umzug in die Org, Pushes nachtragen);
4. setzt `kind`, `forCustomer`, `kindSetBy` (Mitarbeiter aus dem JWT), `kindSetAt`;
5. bei Website + Preview: `updateWebsiteProject(id, { subdomain, projectType: 'preview' })`.
Scheitert Schritt 3 nach einem neu angelegten Ticket, wird das Ticket wieder gelöscht (kein leeres Ticket).
Antwort `{ ticketId }` ⇒ WOMS springt ins Ticket.
Weitere Einstiege:
- WOMS „Neues Repo anlegen“ (Projekte): nach Name/Vorlage direkt weiter in diesen Ablauf.
- Ticket ⇒ „Projekt zuweisen“: Kunde/Ticket bekannt; fehlt die Art, fragt ein kleiner Dialog nur Schritt 1.
- Bereits zugeordnet (`kind` gesetzt) ⇒ Seite zeigt „Bereits erledigt von <Name> am <Datum>“ mit Link zum
Ticket; Art bleibt dort änderbar.
## 3. Bereich „Services“ (WOMS)
Menüpunkt **„Services“** neben „Projekte“, Route `/services`. Daten: `GET /api/admin/services` liefert
Katalog + Projekte gruppiert nach `kind` (Repo, Kunde, Ticket, Preview-URL, Funktionszustände).
- Eine Karte je Art: Name, Beschreibung, Anzahl, **mögliche Funktionen** mit Status (Website-Analyse:
*In Entwicklung*), **Projekte dieser Art** als Liste (Klick ⇒ Ticket bzw. Projekt).
- Karte **„Art fehlt“**: alle Repos ohne `kind`, je mit Knopf „Zuordnen“ ⇒ Ablauf aus Abschnitt 2.
- Kein Umschalten hier. Umschalten der Funktionen kommt mit Teilprojekt 3 **am Projekt** (Ticket ⇒ Projekte).
- Kundenportal: Tab „Funktionen“ zeigt dem Kunden nur Funktionen, die laut Katalog zur Art seines Projekts
passen (Anbindung an die Funktionen-Spec, sobald diese live ist).
## Fehlerfälle
- Gitea-Login ohne passenden Mitarbeiter ⇒ keine Mail, Projekt steht in „Art fehlt“.
- SMTP-Fehler ⇒ `assignMailSentAt` bleibt leer, nächster Job-Lauf versucht es erneut (höchstens 3 Versuche).
- Zwei Personen schließen gleichzeitig ab ⇒ zweiter Aufruf bekommt 409 „bereits zugeordnet“.
- Appwrite nicht erreichbar ⇒ Job überspringt den Lauf, keine Teil-Zustände.
## Tests
- Unit (Portal, `node --test`): Katalog, Eingabeprüfung, Mail-Auswahl „nur neue Repos“, Erinnerung,
Rückbau des Tickets bei Fehler, 409 bei Doppel-Abschluss.
- WOMS (vitest): Schritt-Logik des Ablaufs, Vorbelegung des neuen Tickets.
- Playwright gegen Fixture-Daten: Ablauf komplett (intern, Kunde + vorhandenes Ticket, Kunde + neues Ticket),
Services-Seite.
- Live-Test mit dem Nutzer: Repo in Gitea anlegen ⇒ Mail ⇒ Ablauf ⇒ Ticket/Preview prüfen; danach aufräumen.
## Livegang (je Schritt mit OK des Nutzers)
L1 Schema-Skript · L2 Portal pushen (main) · L3 Hook 112 um `repository` erweitern · L4 WOMS pushen (test) ·
L5 Live-Test mit dem Nutzer · danach erst Teilprojekt 2.
## Nicht Teil dieser Spec
Preview-Modi/Dev-Server (Teilprojekt 2), Analyse und deren Rechtstexte (Teilprojekt 3), weitere Funktionen
im Katalog (werden am Ende beim Nutzer abgefragt), Umbenennen/Abschaffen von `projectType`.
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env node
/**
* Projektarten: legt die neuen Attribute an der Collection websiteProjects an.
* Idempotent: vorhandene Attribute werden uebersprungen.
* Usage:
* node scripts/projektarten-setup.mjs --check (nur melden, nichts anlegen)
* node scripts/projektarten-setup.mjs (fehlende Attribute anlegen)
*/
import { pathToFileURL } from 'node:url'
import { config } from '../server/config.js'
export const PROJECT_KIND_ATTRIBUTES = [
{ key: 'kind', type: 'string', size: 20, required: false },
{ key: 'forCustomer', type: 'boolean', required: false },
{ key: 'kindSetBy', type: 'string', size: 64, required: false },
{ key: 'kindSetAt', type: 'string', size: 40, required: false },
{ key: 'createdByGitea', type: 'string', size: 64, required: false },
{ key: 'assignMailSentAt', type: 'string', size: 40, required: false },
{ key: 'assignReminderSentAt', type: 'string', size: 40, required: false },
{ key: 'assignMailAttempts', type: 'integer', required: false },
]
/** Echter Appwrite-Client (REST) fuer die Collection websiteProjects. */
export function createAppwriteClient() {
const { endpoint, projectId, databaseId, apiKey } = config.appwrite
const collectionId = config.collections.websiteProjects
const base = `${endpoint.replace(/\/$/, '')}/databases/${databaseId}/collections/${collectionId}`
async function aw(path, { method = 'GET', body } = {}) {
const response = await fetch(`${base}${path}`, {
method,
headers: {
'Content-Type': 'application/json',
'X-Appwrite-Project': projectId,
'X-Appwrite-Key': apiKey,
},
body: body ? JSON.stringify(body) : undefined,
})
const data = await response.json().catch(() => ({}))
if (!response.ok) throw new Error(data.message || `Appwrite ${response.status}`)
return data
}
return {
async listAttributes() {
// Ohne limit liefert Appwrite nur 25 Attribute - die Sammlung hat mehr
const q = encodeURIComponent(JSON.stringify({ method: 'limit', values: [200] }))
const data = await aw(`/attributes?queries[]=${q}`)
return data.attributes || []
},
async createAttribute({ type, ...body }) {
await aw(`/attributes/${type}`, { method: 'POST', body })
},
async waitForAttribute(key, maxSec = 30) {
for (let i = 0; i < maxSec; i += 1) {
const attr = await aw(`/attributes/${key}`).catch(() => null)
if (attr?.status === 'available') return
await new Promise((r) => setTimeout(r, 1000))
}
throw new Error(`Attribut ${key} wurde nicht verfuegbar`)
},
}
}
export async function main({ check = false, client, log = console.log } = {}) {
const api = client || createAppwriteClient()
const existing = new Set((await api.listAttributes()).map((a) => a.key))
const missing = PROJECT_KIND_ATTRIBUTES.filter((a) => !existing.has(a.key))
for (const attr of PROJECT_KIND_ATTRIBUTES) {
if (existing.has(attr.key)) log(`= Attribut ${attr.key} (existiert)`)
else if (check) log(`- Attribut ${attr.key} FEHLT`)
}
if (check) {
log(missing.length ? `${missing.length} Attribut(e) fehlen.` : 'Alle Attribute vorhanden.')
return { missing }
}
for (const attr of missing) {
await api.createAttribute(attr)
log(`+ Attribut ${attr.key}`)
}
for (const attr of missing) await api.waitForAttribute(attr.key)
log('Fertig.')
return { missing }
}
const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
if (invokedDirectly) {
const check = process.argv.includes('--check')
main({ check })
.then(({ missing }) => process.exit(check && missing.length ? 1 : 0))
.catch((e) => {
console.error(e.message)
process.exit(1)
})
}
+11
View File
@@ -1,3 +1,4 @@
import { runRepoAssignJob } from './services/repoAssignJob.js'
import express from 'express'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -22,6 +23,7 @@ import giteaWebhookRoutes from './routes/webhook/gitea.js'
import pushTimeRoutes from './routes/pushTime.js'
import adminGiteaRoutes from './routes/admin/gitea.js'
import adminWebsiteProjectsRoutes from './routes/admin/websiteProjects.js'
import adminServicesRoutes from './routes/admin/services.js'
import adminCustomersRoutes from './routes/admin/customers.js'
import adminEmployeesRoutes from './routes/admin/employees.js'
import adminInvoiceClientsRoutes from './routes/admin/invoiceClients.js'
@@ -71,6 +73,7 @@ app.use('/api/funktionen', funktionenRoutes)
app.use('/api/portal-admin', portalAdminRoutes)
app.use('/api/admin/gitea', adminGiteaRoutes)
app.use('/api/admin/website-projects', adminWebsiteProjectsRoutes)
app.use('/api/admin/services', adminServicesRoutes)
app.use('/api/admin/customers', adminCustomersRoutes)
app.use('/api/admin/employees', adminEmployeesRoutes)
app.use('/api/admin/invoice-clients', adminInvoiceClientsRoutes)
@@ -159,6 +162,14 @@ const server = app.listen(config.port, '0.0.0.0', () => {
.then((done) => done.length && console.log(`[funktionen] ${done.length} Funktion(en) freigeschaltet`))
.catch((err) => console.warn('[funktionen] Freischalten fehlgeschlagen:', err.message))
setInterval(activateFeatures, 5 * 60 * 1000).unref()
// Neue Gitea-Repos erkennen, Zuordnungs-Mail senden, Erinnerungen/Wiederholungen (alle 10 Minuten)
const repoAssign = () =>
runRepoAssignJob({ now: new Date() }).catch((err) =>
console.warn('[repoAssign] Lauf fehlgeschlagen:', err.message)
)
setTimeout(repoAssign, 60 * 1000).unref()
setInterval(repoAssign, 10 * 60 * 1000).unref()
})
server.on('error', (err) => {
@@ -0,0 +1,57 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
T17 · Neues Repo zuordnen
Betreff: {{betreff_vorsatz}}Neues Repo zuordnen: {{repo}}
Absender: „WEBklar Kundenportal“ <Portal-Postfach>
Empfänger: die Person, die das Repo in Gitea angelegt hat (employees.email)
Platzhalter: {{betreff_vorsatz}}, {{vorname}}, {{repo}}, {{repo_link}}, {{angelegt_von}}, {{angelegt_am}}, {{zuordnen_link}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): erinnerung
betreff_vorsatz ist leer oder „Erinnerung: “; der Block erinnerung (Hinweis auf die Erinnerung nach 24 Stunden) erscheint nur in der Erinnerung; zuordnen_link öffnet im Ticketsystem die Zuordnung (Art, Kunde, Ticket).
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>{{betreff_vorsatz}}Neues Repo zuordnen: {{repo}}</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.btn2{background:#2a2a28!important;border-color:#3a3a37!important;}.btn2 a{color:#f2f1ee!important;}.rowline{border-color:#3a3a37!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">Neues Repo {{repo}} – ordne es kurz zu.&#8199;&#847;&#8199;&#847;&#8199;&#847;</span>
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Intern · Ticketsystem</td>
</tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#fdebd3;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#8a4b00;">Repo zuordnen</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Hallo {{vorname}}, zu welchem Projekt gehört dein neues Repo?</td></tr>
<tr><td class="px mut" style="padding:10px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Du hast {{repo}} in Gitea angelegt. Wähle im Ticketsystem die Art des Projekts und den Kunden aus – danach entsteht das Ticket.</td></tr>
<!-- WENN erinnerung -->
<tr><td class="px" style="padding:20px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:14px 20px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;">Das Repo ist seit gestern nicht zugeordnet. Das ist die einzige Erinnerung.</td></tr></table></td></tr>
<!-- ENDE erinnerung -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;">
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:10px 12px 10px 0;color:#6b6964;border-bottom:1px solid #eeece6;">Repo</td><td class="val ink rowline" valign="top" style="padding:10px 0;color:#111111;border-bottom:1px solid #eeece6;">{{repo}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:10px 12px 10px 0;color:#6b6964;border-bottom:1px solid #eeece6;">Gitea</td><td class="val ink rowline" valign="top" style="padding:10px 0;color:#111111;border-bottom:1px solid #eeece6;"><a href="{{repo_link}}" style="color:#111111;">{{repo_link}}</a></td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:10px 12px 10px 0;color:#6b6964;border-bottom:1px solid #eeece6;">Angelegt von</td><td class="val ink rowline" valign="top" style="padding:10px 0;color:#111111;border-bottom:1px solid #eeece6;">{{angelegt_von}}</td></tr>
<tr><td class="lbl mut rowline" width="150" valign="top" style="width:150px;padding:10px 12px 10px 0;color:#6b6964;border-bottom:1px solid #eeece6;">Angelegt am</td><td class="val ink rowline" valign="top" style="padding:10px 0;color:#111111;border-bottom:1px solid #eeece6;">{{angelegt_am}}</td></tr>
</table></td></tr>
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{zuordnen_link}}" style="display:block;padding:12px 22px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Jetzt zuordnen &rarr;</a></td></tr></table></td></tr>
<tr><td style="padding:0 0 40px 0;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Du bekommst diese Mail, weil du das Repo angelegt hast. Es folgt höchstens eine Erinnerung nach 24 Stunden.</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
+64
View File
@@ -0,0 +1,64 @@
import { Router } from 'express'
import { config } from '../../config.js'
import { listDocuments, Query } from '../../services/appwriteAdmin.js'
import { servicesCatalog } from '../../services/projectKinds.js'
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
const router = Router()
router.use(requireAppwriteStaff)
const CHUNK = 100
// Lädt Dokumente per $id in Blöcken (ein Request je 100 IDs statt je Projekt)
async function loadByIds(collectionId, ids) {
const map = new Map()
const unique = [...new Set(ids.filter(Boolean))]
for (let i = 0; i < unique.length; i += CHUNK) {
const part = unique.slice(i, i + CHUNK)
const res = await listDocuments(collectionId, [Query.equal('$id', part), Query.limit(CHUNK)])
for (const doc of res || []) map.set(doc.$id, doc)
}
return map
}
function previewUrlFor(p) {
if (p.liveDomain) return `https://${p.liveDomain}`
if (p.subdomain) return `https://${p.subdomain}.project.webklar.com`
return ''
}
router.get('/', async (_req, res) => {
try {
const { kinds, features } = servicesCatalog()
const projRes = await listDocuments(config.collections.websiteProjects, [Query.limit(5000)])
const projects = (projRes || []).filter((p) => !p.archived && !p.isTemplate)
const [customers, tickets] = await Promise.all([
loadByIds(config.collections.customers, projects.map((p) => p.customerId)),
loadByIds(config.collections.workorders, projects.map((p) => p.ticketId)),
])
const groups = {}
for (const p of projects) {
const key = kinds.some((k) => k.key === p.kind) ? p.kind : 'missing'
;(groups[key] ||= []).push({
id: p.$id,
projectName: p.projectName || '',
repoFullName: p.repoFullName || '',
customerId: p.customerId || '',
customerName: customers.get(p.customerId)?.name || '',
ticketId: p.ticketId || '',
woid: tickets.get(p.ticketId)?.woid || '',
previewUrl: previewUrlFor(p),
features: {},
})
}
return res.json({ kinds, features, groups })
} catch (err) {
return res.status(err.status || 500).json({ error: err.message || 'Services konnten nicht geladen werden' })
}
})
export default router
+16
View File
@@ -5,6 +5,7 @@ import {
archiveRepo,
} from '../../services/giteaAdmin.js'
import { assignProject, reconcileProject } from '../../services/projectAssignment.js'
import { completeAssignment } from '../../services/projectKindAssign.js'
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
const router = Router()
@@ -90,6 +91,21 @@ router.post('/:id/assign', async (req, res) => {
}
})
// Zuordnen-Flow (WOMS): Art speichern, ggf. Ticket anlegen, Kunde/Ticket zuweisen, Preview
router.post('/:id/zuordnen', async (req, res) => {
const staffName = req.appwriteUser?.name || req.appwriteUser?.email || ''
try {
const result = await completeAssignment(req.params.id, req.body || {}, { staffName })
return res.json(result)
} catch (err) {
if (err.status === 409) {
return res.status(409).json({ error: 'bereits zugeordnet', kindSetBy: err.kindSetBy || '', kindSetAt: err.kindSetAt || '' })
}
const status = err.status || 500
return res.status(status).json({ error: err.message })
}
})
// Umzug + Pushes eines bereits zugewiesenen Projekts erneut nachtragen
router.post('/:id/pushes', async (req, res) => {
try {
+15
View File
@@ -15,6 +15,7 @@ import { ensureProjectTicket } from '../../services/ticketAdmin.js'
import { invalidateProjectCache } from '../../services/projectInsights.js'
import { withLocks } from '../../services/keyedLock.js'
import { repoLockKey, currentRepoFullName } from '../../services/repoMove.js'
import { handleRepositoryCreated } from '../../services/repoAssignJob.js'
const router = Router()
@@ -45,6 +46,20 @@ router.post('/gitea', async (req, res) => {
}
const payload = req.body || {}
// Ereignis "repository": nur `created` wird verarbeitet (neues Repo -> Dokument + Zuordnungs-Mail),
// alles andere (deleted, transfer, ...) wird mit 200 ignoriert. Der Push-Pfad bleibt unberuehrt.
if (req.get('X-Gitea-Event') === 'repository') {
if (payload.action !== 'created') return res.json({ ok: true, ignored: true })
try {
const r = await handleRepositoryCreated(payload)
return res.json({ ok: true, created: r.created })
} catch (err) {
console.error('[webhook] repository/created:', err)
return res.status(500).json({ error: err.message || 'Webhook-Verarbeitung fehlgeschlagen' })
}
}
const ref = payload.ref || ''
const repo = payload.repository || {}
const payloadName = repo.full_name || ''
+4 -2
View File
@@ -68,6 +68,7 @@ export async function importNewRepos() {
)
let created = 0
const createdRepos = []
for (const repo of repos) {
const fullName = repo.full_name
if (!fullName || repo.archived) continue
@@ -80,7 +81,7 @@ export async function importNewRepos() {
])
if (recheck[0]) return
await upsertWebsiteProjectByRepo(fullName, {
const doc = await upsertWebsiteProjectByRepo(fullName, {
projectName: repo.description || repo.name || fullName.split('/').pop(),
giteaRepoUrl: repo.html_url,
giteaRepoName: repo.name,
@@ -90,10 +91,11 @@ export async function importNewRepos() {
giteaSyncedAt: new Date().toISOString(),
})
created += 1
createdRepos.push({ projectId: doc.$id, repo })
})
}
return { created }
return { created, createdRepos }
})
}
+112
View File
@@ -0,0 +1,112 @@
import { config } from '../config.js'
import { getDocument, updateDocument } from './appwriteAdmin.js'
import { isKind, kindLabel, ticketTypeFor } from './projectKinds.js'
import { createTicketForProject, deleteTicket } from './ticketAdmin.js'
import { assignProject } from './projectAssignment.js'
import { updateWebsiteProject } from './giteaAdmin.js'
import { withLocks } from './keyedLock.js'
function fail(status, message, extra = {}) {
const err = new Error(message)
err.status = status
Object.assign(err, extra)
return err
}
async function loadOrNull(collection, id) {
if (!id) return null
return getDocument(collection, id).catch(() => null)
}
/**
* Schliesst das Zuordnen eines Projekts ab (WOMS-Flow): speichert die Projektart, legt bei
* Bedarf erst JETZT ein Ticket an, weist Kunde/Ticket zu und schaltet optional die Preview ein.
* Intern (`forCustomer: false`): nur Art speichern, `ticketId: null`.
*
* Serialisiert ueber den EIGENEN Schluessel 'zuordnen:<id>' (R6): `withLocks` ist nicht
* reentrant, und `assignProject` nimmt 'project:<id>' bzw. die Repo-Sperren selbst.
*
* `deps` ist nur fuer Tests gedacht (Gitea-Aufrufe ersetzen).
*/
export async function completeAssignment(
projectId,
{ kind, forCustomer, customerId = '', ticketId = '', newTicket = null, preview = null } = {},
{ staffName = '', now = new Date(), deps = {} } = {}
) {
const assign = deps.assignProject || assignProject
const updateProject = deps.updateWebsiteProject || updateWebsiteProject
return withLocks(['zuordnen:' + projectId], async () => {
const project = await getDocument(config.collections.websiteProjects, projectId).catch((err) => {
if (err.status === 404) throw fail(404, 'Projekt nicht gefunden')
throw err
})
if (project.kind) {
throw fail(409, 'bereits zugeordnet', { kindSetBy: project.kindSetBy || '', kindSetAt: project.kindSetAt || '' })
}
if (!isKind(kind)) throw fail(400, 'Unbekannte Projektart.')
const customer = !!forCustomer
let createdTicketId = null
let finalTicketId = null
const warnings = []
if (customer) {
if (!(await loadOrNull(config.collections.customers, customerId))) throw fail(400, 'Kunde nicht gefunden.')
if (!!ticketId === !!newTicket) throw fail(400, 'Bitte genau eins angeben: vorhandenes Ticket oder neues Ticket.')
if (ticketId) {
const ticket = await loadOrNull(config.collections.workorders, ticketId)
if (!ticket) throw fail(400, 'Ticket nicht gefunden.')
if (ticket.customerId !== customerId) throw fail(400, 'Das Ticket gehört einem anderen Kunden.')
if (ticket.status === 'Closed' || ticket.status === 'Cancelled') throw fail(400, 'Das Ticket ist bereits abgeschlossen.')
finalTicketId = ticketId
} else {
const repoName = String(project.repoFullName || '').split('/').pop()
const topic = String(newTicket.topic || '').trim() || `${kindLabel(kind)} – ${repoName}`
const created = await createTicketForProject(
{
customerId,
topic,
type: ticketTypeFor(kind),
requestedBy: newTicket.requestedBy,
startDate: newTicket.startDate,
details: newTicket.details,
},
{ now }
)
createdTicketId = created.$id
finalTicketId = created.$id
}
try {
const assigned = await assign(projectId, { customerId, ticketId: finalTicketId }, { now })
// assignProject schluckt Umzug-/Nachtrage-Fehler und meldet sie als Warnungen.
if (assigned?.warnings?.length) warnings.push(...assigned.warnings)
} catch (err) {
if (createdTicketId) await deleteTicket(createdTicketId).catch(() => {})
throw err
}
}
await updateDocument(config.collections.websiteProjects, projectId, {
kind,
forCustomer: customer,
kindSetBy: staffName || '',
kindSetAt: now.toISOString(),
})
const result = { ticketId: finalTicketId }
if (kind === 'website' && customer && preview?.subdomain) {
try {
await updateProject(projectId, { subdomain: preview.subdomain, projectType: 'preview' })
} catch (err) {
// Art und Zuordnung sind gespeichert - die Preview laesst sich nachtraeglich einschalten.
warnings.push(`Die Preview konnte nicht eingeschaltet werden (${err.message}).`)
}
}
if (warnings.length) result.warnings = warnings
return result
})
}
+28
View File
@@ -0,0 +1,28 @@
// Katalog der Projektarten und ihrer Zusatzfunktionen.
// preview: 'yes' = Vorschau immer, 'possible' = möglich, 'no' = keine.
export const FEATURES = {
analyse: { label: 'Website-Analyse', status: 'in_development' },
}
export const KINDS = [
{ key: 'website', label: 'Website', preview: 'yes', features: ['analyse'], ticketType: 'Webpage',
description: 'Internetauftritt inkl. Landingpage oder Shop' },
{ key: 'webapp', label: 'Web-App', preview: 'possible', features: [], ticketType: 'Webpage',
description: 'Individuelle Anwendung im Browser, z. B. Portal oder Buchungssystem' },
{ key: 'app', label: 'App', preview: 'no', features: [], ticketType: 'Webpage',
description: 'Mobile App für iOS und Android' },
{ key: 'migration', label: 'Migration/Umzug', preview: 'no', features: [], ticketType: 'Migration',
description: 'Umzug bestehender Seiten, Server oder Systeme' },
{ key: 'automation', label: 'Automation/Skript', preview: 'no', features: [], ticketType: 'Webpage',
description: 'Automatisierte Abläufe, Schnittstellen und Skripte' },
{ key: 'other', label: 'Sonstiges', preview: 'no', features: [], ticketType: 'Webpage',
description: 'Alles, was in keine andere Art passt' },
]
const find = (key) => KINDS.find(k => k.key === key)
export const isKind = (key) => !!find(key)
export const kindLabel = (key) => find(key)?.label || ''
export const ticketTypeFor = (key) => (find(key)?.ticketType === 'Migration' ? 'Migration' : 'Webpage')
export const servicesCatalog = () => ({ kinds: KINDS, features: FEATURES })
+142
View File
@@ -0,0 +1,142 @@
import { config } from '../config.js'
import { listDocuments, updateDocument, getDocument, upsertWebsiteProjectByRepo, Query } from './appwriteAdmin.js'
import { importNewRepos } from './giteaAdmin.js'
import { giteaFetch } from './giteaClient.js'
import { withLocks } from './keyedLock.js'
import { repoLockKey } from './repoMove.js'
import { shouldSendAssignMail, dueReminder, sendRepoAssignMail } from './repoAssignMail.js'
/**
* Erkennung neuer Repos (Task 7): der Webhook `repository/created` und der 10-Minuten-Job laufen
* ueber dieselbe Repo-Sperre (repoLockKey) und legen das Projekt-Dokument hoechstens einmal an.
* Die Mail geht ausserhalb dieser Sperre raus, unter einer eigenen Sperre je Projekt, die vor dem
* Senden den Stand frisch liest - so kann kein zweiter Pfad dieselbe Mail doppelt verschicken (R7).
*/
// Ein vom Job angelegtes Org-Dokument, das der Webhook kurz danach meldet, gilt noch als "neu".
const RACE_WINDOW_MS = 15 * 60 * 1000
export const isOrgOwner = (repo) =>
String(repo?.owner?.login || '').toLowerCase() === String(config.gitea.org || '').toLowerCase()
const collection = () => config.collections.websiteProjects
/**
* Mail senden, falls nach frischem Stand faellig. `createdNow` bedeutet: dies ist der Pfad, der das
* Dokument gerade angelegt hat (oder die Wiederholung einer fehlgeschlagenen ersten Mail).
*/
async function sendFirstMailIfDue(projectId, repo, giteaLogin, { now, createdNow, sendMail }) {
return withLocks(['assignmail:' + projectId], async () => {
const fresh = await getDocument(collection(), projectId)
if (!shouldSendAssignMail(fresh, repo, { now, createdNow })) return { sent: false, reason: 'not_due' }
return sendMail(fresh, { giteaLogin, reminder: false, repoCreatedAt: repo.created_at })
})
}
/**
* Webhook `repository` / `created`: Dokument anlegen (falls fehlt), Ersteller merken, Mail senden.
* Gibt { created, mail } zurueck; `mail` ist das Ergebnis von sendRepoAssignMail oder null.
*/
export async function handleRepositoryCreated(payload, { now = new Date(), sendMail = sendRepoAssignMail } = {}) {
const repo = payload?.repository || {}
const fullName = repo.full_name
const login = payload?.sender?.login || ''
if (!fullName || repo.archived) return { created: false, mail: null }
let projectId = ''
let created = false
let takeOver = false
await withLocks(
[repoLockKey(fullName)],
async () => {
const existing = (
await listDocuments(collection(), [Query.equal('repoFullName', fullName), Query.limit(1)])
)[0]
if (existing) {
projectId = existing.$id
// Vom Job Sekunden zuvor angelegtes Org-Repo ohne Ersteller: der Webhook kennt den Ersteller.
const young = now.getTime() - new Date(existing.$createdAt || 0).getTime() < RACE_WINDOW_MS
if (young && login && !existing.createdByGitea && !existing.kind && !existing.assignMailSentAt &&
!existing.customerId && !existing.ticketId && !Number(existing.assignMailAttempts || 0)) {
await updateDocument(collection(), existing.$id, { createdByGitea: login })
takeOver = true
}
return
}
const doc = await upsertWebsiteProjectByRepo(fullName, {
projectName: repo.description || repo.name || fullName.split('/').pop(),
giteaRepoUrl: repo.html_url || `${config.gitea.baseUrl}/${fullName}`,
giteaRepoName: repo.name || fullName.split('/').pop(),
repoFullName: fullName,
archived: false,
status: 'synced',
giteaSyncedAt: new Date().toISOString(),
createdByGitea: login,
})
projectId = doc.$id
created = true
},
{ waitMs: 10000 }
)
if (!created && !takeOver) return { created, mail: null }
const mail = await sendFirstMailIfDue(projectId, repo, login, { now, createdNow: true, sendMail })
return { created, mail }
}
/** Ein Lauf: neue Repos importieren + Mail, Wiederholungen, Erinnerungen. Wirft bei Appwrite-Fehlern nicht. */
export async function runRepoAssignJob({
now = new Date(),
sendMail = sendRepoAssignMail,
fetchRepo = (fullName) => giteaFetch(`/repos/${fullName}`),
} = {}) {
const counts = { imported: 0, mails: 0, retries: 0, reminders: 0, error: null }
try {
const { createdRepos = [] } = await importNewRepos()
counts.imported = createdRepos.length
const handled = new Set() // in diesem Lauf schon versucht: nicht sofort wiederholen
for (const { projectId, repo } of createdRepos) {
// Org-Repos: Ersteller unbekannt, der Webhook-Sender deckt sie ab.
if (isOrgOwner(repo)) continue
const login = repo.owner?.login || ''
if (!login) continue
// Alte Repos (>= 24 h) bekommen keinen Ersteller: sie werden nie gemailt und nie erinnert.
const age = now.getTime() - new Date(repo.created_at || 0).getTime()
if (!(age < 24 * 3600_000)) continue
handled.add(projectId)
await updateDocument(collection(), projectId, { createdByGitea: login })
const r = await sendFirstMailIfDue(projectId, repo, login, { now, createdNow: true, sendMail })
if (r.sent) counts.mails += 1
}
const docs = await listDocuments(collection(), [Query.limit(5000)])
for (const p of docs) {
if (!p.createdByGitea || p.kind || handled.has(p.$id)) continue
// Wiederholung/Nachholen: erste Mail noch nicht raus (0-2 Versuche), Repo noch juenger als 24 h.
const attempts = Number(p.assignMailAttempts || 0)
if (!p.assignMailSentAt && attempts < 3) {
let repo
try {
repo = await fetchRepo(p.repoFullName)
} catch (err) {
console.warn(`[repoAssign] Repo ${p.repoFullName} nicht lesbar:`, err.message)
continue
}
const r = await sendFirstMailIfDue(p.$id, repo, p.createdByGitea, { now, createdNow: true, sendMail })
if (r.sent) counts.retries += 1
} else if (dueReminder(p, { now })) {
const r = await withLocks(['assignmail:' + p.$id], async () => {
const fresh = await getDocument(collection(), p.$id)
if (!dueReminder(fresh, { now })) return { sent: false }
return sendMail(fresh, { giteaLogin: fresh.createdByGitea, reminder: true })
})
if (r.sent) counts.reminders += 1
}
}
} catch (err) {
counts.error = err.message
console.error('[repoAssign] Lauf abgebrochen:', err.message)
}
return counts
}
+86
View File
@@ -0,0 +1,86 @@
import { config } from '../config.js'
import { berlinDateTime } from './berlinTime.js'
import { listDocuments, updateDocument, Query } from './appwriteAdmin.js'
import { sendStaffMail } from './mailer.js'
import { findEmployeeForPusher } from './pushTimeMail.js'
/**
* Mail "Neues Repo zuordnen" (Vorlage T17): wer in Gitea ein Repo anlegt, bekommt eine Mail mit
* Knopf in die Zuordnung im Ticketsystem. Eine Erinnerung nach 24 Stunden, danach nichts mehr.
*/
const DAY_MS = 24 * 60 * 60 * 1000
const MAX_ATTEMPTS = 3
const time = (value) => {
const ms = new Date(value || '').getTime()
return Number.isNaN(ms) ? null : ms
}
/**
* Soll fuer dieses Repo die erste Mail raus? Nur wenn das Projekt-Dokument jetzt neu angelegt wurde,
* das Gitea-Repo juenger als 24 h ist, noch keine Art gesetzt ist und noch keine Mail ging.
*/
export function shouldSendAssignMail(project, repo, { now = new Date(), createdNow = false } = {}) {
if (!createdNow) return false
if (project?.kind || project?.assignMailSentAt) return false
if (Number(project?.assignMailAttempts || 0) >= MAX_ATTEMPTS) return false
const created = time(repo?.created_at)
if (created === null) return false
return new Date(now).getTime() - created < DAY_MS
}
/** Erinnerung faellig: Mail ging vor >= 24 h, Art fehlt noch, noch keine Erinnerung. */
export function dueReminder(project, { now = new Date() } = {}) {
if (project?.kind || project?.assignReminderSentAt) return false
if (Number(project?.assignMailAttempts || 0) >= MAX_ATTEMPTS) return false
const sent = time(project?.assignMailSentAt)
if (sent === null) return false
return new Date(now).getTime() - sent >= DAY_MS
}
/** Mail verschicken; wirft nie (Webhook/Sync darf daran nicht scheitern). */
export async function sendRepoAssignMail(project, { giteaLogin, reminder = false, repoCreatedAt } = {}) {
try {
const employees = await listDocuments(config.collections.employees, [Query.limit(100)])
const employee = findEmployeeForPusher({ username: giteaLogin }, employees)
if (!employee) {
console.warn(`[repoAssignMail] Gitea-Login "${giteaLogin || '?'}" keinem Mitarbeiter mit E-Mail zugeordnet – keine Mail`)
return { sent: false, reason: 'no_employee' }
}
const created = new Date(repoCreatedAt || project.$createdAt || Date.now())
const { date, time: hhmm } = berlinDateTime(Number.isNaN(created.getTime()) ? new Date() : created)
const attempts = Number(project.assignMailAttempts || 0) + 1
const message = {
template: 'T17',
values: {
betreff_vorsatz: reminder ? 'Erinnerung: ' : '',
vorname: String(employee.displayName || '').trim().split(/\s+/)[0] || 'du',
repo: project.repoFullName,
repo_link: project.giteaRepoUrl,
angelegt_von: employee.displayName || giteaLogin,
angelegt_am: `${date} ${hhmm.slice(0, 2)}:${hhmm.slice(2)}`,
zuordnen_link: `https://ticket.webklar.com/projects/zuordnen/${project.$id}`,
},
blocks: { erinnerung: Boolean(reminder) },
}
const patch = { assignMailAttempts: attempts }
try {
await sendStaffMail(employee.email, message)
} catch (err) {
console.error('[repoAssignMail] Versand fehlgeschlagen:', err.message)
await updateDocument(config.collections.websiteProjects, project.$id, patch)
return { sent: false, reason: err.message }
}
// assignMailAttempts zaehlt die Versuche der aktuellen Mail: nach Erfolg zurueck auf 0 (R7)
patch.assignMailAttempts = 0
patch[reminder ? 'assignReminderSentAt' : 'assignMailSentAt'] = new Date().toISOString()
await updateDocument(config.collections.websiteProjects, project.$id, patch)
return { sent: true }
} catch (err) {
console.error('[repoAssignMail] Fehler:', err.message)
return { sent: false, reason: err.message }
}
}
+67
View File
@@ -1,6 +1,8 @@
import { config } from '../config.js'
import { berlinDateTime } from './berlinTime.js'
import {
createDocument,
deleteDocument,
getDocument,
listDocuments,
updateDocument,
@@ -105,3 +107,68 @@ export async function createFeatureTicket({ customerId, project, featureKey, sit
console.log(`[ticketAdmin] Funktions-Ticket #${woid} (${featureKey}) fuer Projekt ${project?.$id} angelegt`)
return { ticketId: ticket.$id, woid }
}
/**
* Legt ein Ticket fuer ein Projekt an (Zuordnen-Flow, erst beim Abschliessen).
* Unbekannter Kunde => Fehler mit status 400.
*/
export async function createTicketForProject({ customerId, topic, type, requestedBy, startDate, details }, { now = new Date() } = {}) {
const customer = customerId
? await getDocument(config.collections.customers, customerId).catch(() => null)
: null
if (!customer) {
const err = new Error('Kunde nicht gefunden.')
err.status = 400
throw err
}
// WOMS speichert Ticket-Daten als 'TT.MM.JJJJ'; leer = heute (Berlin).
let date
if (!startDate) {
date = berlinDateTime(now).date
} else {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(startDate))
const valid =
m &&
(() => {
const d = new Date(Date.UTC(+m[1], +m[2] - 1, +m[3]))
return d.getUTCFullYear() === +m[1] && d.getUTCMonth() === +m[2] - 1 && d.getUTCDate() === +m[3]
})()
if (!valid) {
const err = new Error('Das Startdatum ist ungültig (erwartet: JJJJ-MM-TT).')
err.status = 400
throw err
}
date = `${m[3]}.${m[2]}.${m[1]}`
}
const woid = await generateNextWoid()
const ticket = await createDocument(
config.collections.workorders,
{
topic,
type,
status: 'Open',
priority: 1,
serviceType: 'Remote',
systemType: 'n/a',
customerId,
customerName: customer.name || '',
customerLocation: customer.location || '',
requestedBy: requestedBy || '',
startDate: date,
deadline: date,
details: details || '',
woid,
createdAt: new Date().toISOString(),
},
ID.unique()
)
console.log(`[ticketAdmin] Ticket #${woid} fuer Kunde ${customerId} angelegt`)
return { $id: ticket.$id, woid }
}
/** Loescht ein Ticket wieder (Rollback, wenn der Zuordnen-Flow scheitert). */
export async function deleteTicket(ticketId) {
await deleteDocument(config.collections.workorders, ticketId)
}
+4 -1
View File
@@ -54,7 +54,10 @@ after(async () => {
})
test('importNewRepos legt nur fehlende an', async () => {
assert.deepEqual(await importNewRepos(), { created: 2 })
const r = await importNewRepos()
assert.equal(r.created, 2)
assert.deepEqual(r.createdRepos.map((x) => x.repo.full_name).sort(), ['JUSN/neu', 'knso/privat'])
assert.ok(r.createdRepos.every((x) => x.projectId))
const neu = aw.all('websiteProjects').find((d) => d.repoFullName === 'JUSN/neu')
assert.deepEqual([neu.projectName, neu.status, neu.subdomain || '', neu.previewUrl || ''], ['Döner Nation', 'synced', '', ''])
assert.ok(aw.all('websiteProjects').some((d) => d.repoFullName === 'knso/privat'))
+1
View File
@@ -137,6 +137,7 @@ export async function startFakeGitea(options = {}) {
private: Boolean(def.private),
description: def.description || '',
empty: false,
created_at: def.created_at || new Date().toISOString(),
}
repos.set(keyFrom(fullName), record)
return record
+3 -2
View File
@@ -6,7 +6,7 @@ import { contractAttachmentFor, termsDocFor } from '../server/services/products.
const DIR = new URL('../server/mail-templates/', import.meta.url)
// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1);
// Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand)
// Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand); T17 Repo zuordnen
const EXPECTED = [
...Array.from({ length: 11 }, (_, i) => `K${String(i + 1).padStart(2, '0')}`),
'K12',
@@ -20,6 +20,7 @@ const EXPECTED = [
'T14',
'T15',
'T16',
'T17',
]
const sampleValues = (tpl) => Object.fromEntries(tpl.placeholders.map((p) => [p, `Wert-${p}`]))
@@ -36,7 +37,7 @@ for (const id of EXPECTED) {
const title = source.match(/<title>([\s\S]*?)<\/title>/)[1]
assert.equal(title, tpl.subject, 'Betreff im Kopf und <title> sind gleich')
// F34: Team-Hinweise tragen [Abo], [Speicher] (Zusatzspeicher-Aufgaben), [Datenschutz] (Loeschfristen) oder [Zeit] (Push-Zeitmail)
assert.equal(id.startsWith('T'), /^\[(Abo|Speicher|Datenschutz|Zeit)\]/.test(tpl.subject) || id === 'T01' || id === 'T16', 'Team-Hinweise ab T02 mit [Abo], [Speicher], [Datenschutz] oder [Zeit] (T16 Funktion bestellt ohne Kennung)')
assert.equal(id.startsWith('T'), /^\[(Abo|Speicher|Datenschutz|Zeit)\]/.test(tpl.subject) || id === 'T01' || id === 'T16' || id === 'T17', 'Team-Hinweise ab T02 mit [Abo], [Speicher], [Datenschutz] oder [Zeit] (T16 Funktion bestellt und T17 Repo zuordnen ohne Kennung)')
const used = new Set([...tpl.body.matchAll(/\{\{(\w+)\}\}/g), ...tpl.subject.matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]))
assert.deepEqual([...used].sort(), [...tpl.placeholders].sort(), 'deklarierte = verwendete Platzhalter')
+239
View File
@@ -0,0 +1,239 @@
import { test, before, after } from 'node:test'
import assert from 'node:assert/strict'
import express from 'express'
import { startFakeAppwrite } from './helpers/fakeAppwrite.js'
let aw
let server
let baseUrl
let completeAssignment
const now = new Date('2026-09-30T10:00:00Z')
const newProj = (id, repo, extra = {}) => ({ $id: id, repoFullName: `JUSN/${repo}`, projectName: repo, customerId: '', ticketId: '', ...extra })
function stubs({ failAssign = false } = {}) {
const calls = { assign: [], update: [] }
return {
calls,
deps: {
assignProject: async (id, data) => {
calls.assign.push([id, data])
if (failAssign) throw new Error('Gitea kaputt')
return { ok: true }
},
updateWebsiteProject: async (id, data) => {
calls.update.push([id, data])
return {}
},
},
}
}
before(async () => {
aw = await startFakeAppwrite({
seed: {
websiteProjects: [
newProj('p1', 'intern'),
newProj('p2', 'vorhanden'),
newProj('p3', 'foo'),
newProj('p4', 'umzug'),
newProj('p5', 'fremd'),
newProj('p6', 'zu'),
newProj('p7', 'art'),
newProj('p8', 'fehler'),
newProj('p9', 'doppelt'),
newProj('p10', 'prev'),
newProj('p11', 'route'),
newProj('p13', 'warn'),
newProj('p14', 'race'),
newProj('p15', 'kunde'),
newProj('p16', 'eins'),
newProj('p12', 'schon', { kind: 'app', kindSetBy: 'Max', kindSetAt: '2026-09-29T08:00:00.000Z' }),
],
workorders: [
{ $id: 't1', woid: '100115', customerId: 'c1', status: 'Open' },
{ $id: 't2', woid: '100116', customerId: 'c2', status: 'Open' },
{ $id: 't3', woid: '100117', customerId: 'c1', status: 'Closed' },
],
customers: [{ $id: 'c1', name: 'Döner Nation', location: 'Berlin' }, { $id: 'c2', name: 'Andere' }],
},
})
Object.assign(process.env, { APPWRITE_ENDPOINT: aw.endpoint, APPWRITE_API_KEY: 'test-key' })
;({ completeAssignment } = await import('../server/services/projectKindAssign.js'))
const { default: routes } = await import('../server/routes/admin/websiteProjects.js')
const app = express()
app.use(express.json())
app.use('/api/admin/website-projects', routes)
server = await new Promise((resolve) => {
const s = app.listen(0, '127.0.0.1', () => resolve(s))
})
baseUrl = `http://127.0.0.1:${server.address().port}`
})
after(async () => {
await new Promise((resolve) => server.close(resolve))
await aw.close()
})
const opts = (deps) => ({ staffName: 'Kenso', now, deps })
test('intern speichert nur Art', async () => {
const s = stubs()
const r = await completeAssignment('p1', { kind: 'other', forCustomer: false }, opts(s.deps))
assert.deepEqual(r, { ticketId: null })
const p = aw.get('websiteProjects', 'p1')
assert.equal(p.kind, 'other')
assert.equal(p.forCustomer, false)
assert.equal(p.kindSetBy, 'Kenso')
assert.equal(p.kindSetAt, now.toISOString())
assert.equal(s.calls.assign.length, 0)
})
test('vorhandenes Ticket wird zugewiesen', async () => {
const s = stubs()
const before = aw.all('workorders').length
const r = await completeAssignment('p2', { kind: 'webapp', forCustomer: true, customerId: 'c1', ticketId: 't1' }, opts(s.deps))
assert.deepEqual(r, { ticketId: 't1' })
assert.deepEqual(s.calls.assign, [['p2', { customerId: 'c1', ticketId: 't1' }]])
assert.equal(aw.all('workorders').length, before)
assert.equal(aw.get('websiteProjects', 'p2').kind, 'webapp')
assert.equal(aw.get('websiteProjects', 'p2').forCustomer, true)
})
test('neues Ticket nur beim Abschliessen, Thema "Website – foo"', async () => {
const s = stubs()
const before = aw.all('workorders').length
const r = await completeAssignment(
'p3',
{ kind: 'website', forCustomer: true, customerId: 'c1', newTicket: { topic: '', requestedBy: 'Max', startDate: '2026-10-05', details: 'd' } },
opts(s.deps)
)
assert.equal(aw.all('workorders').length, before + 1)
const t = aw.get('workorders', r.ticketId)
assert.equal(t.topic, 'Website – foo')
assert.equal(t.type, 'Webpage')
assert.equal(t.status, 'Open')
assert.equal(t.startDate, '05.10.2026')
assert.deepEqual(s.calls.assign, [['p3', { customerId: 'c1', ticketId: r.ticketId }]])
})
test('Migration ergibt Ticket-Art Migration', async () => {
const s = stubs()
const r = await completeAssignment('p4', { kind: 'migration', forCustomer: true, customerId: 'c1', newTicket: { topic: 'Umzug X' } }, opts(s.deps))
const t = aw.get('workorders', r.ticketId)
assert.equal(t.type, 'Migration')
assert.equal(t.topic, 'Umzug X')
})
test('Ticket anderer Kunde ⇒ 400', async () => {
const s = stubs()
await assert.rejects(
() => completeAssignment('p5', { kind: 'website', forCustomer: true, customerId: 'c1', ticketId: 't2' }, opts(s.deps)),
(e) => e.status === 400
)
assert.equal(aw.get('websiteProjects', 'p5').kind, undefined)
})
test('Ticket Closed ⇒ 400', async () => {
const s = stubs()
await assert.rejects(
() => completeAssignment('p6', { kind: 'website', forCustomer: true, customerId: 'c1', ticketId: 't3' }, opts(s.deps)),
(e) => e.status === 400
)
})
test('unbekannte Art ⇒ 400', async () => {
const s = stubs()
await assert.rejects(
() => completeAssignment('p7', { kind: 'nix', forCustomer: false }, opts(s.deps)),
(e) => e.status === 400
)
assert.equal(aw.get('websiteProjects', 'p7').kind, undefined)
})
test('assignProject wirft ⇒ neues Ticket geloescht, kind leer', async () => {
const s = stubs({ failAssign: true })
const before = aw.all('workorders').length
await assert.rejects(
() => completeAssignment('p8', { kind: 'website', forCustomer: true, customerId: 'c1', newTicket: { topic: 'x' } }, opts(s.deps)),
/Gitea kaputt/
)
assert.equal(aw.all('workorders').length, before)
assert.equal(aw.get('websiteProjects', 'p8').kind, undefined)
})
test('zweiter Aufruf ⇒ 409, nur ein Ticket', async () => {
const s = stubs()
const before = aw.all('workorders').length
const body = { kind: 'app', forCustomer: true, customerId: 'c1', newTicket: { topic: 'eins' } }
await completeAssignment('p9', body, opts(s.deps))
await assert.rejects(
() => completeAssignment('p9', body, opts(s.deps)),
(e) => e.status === 409 && e.kindSetBy === 'Kenso'
)
assert.equal(aw.all('workorders').length, before + 1)
})
test('Website mit Subdomain ⇒ updateWebsiteProject(preview)', async () => {
const s = stubs()
await completeAssignment('p10', { kind: 'website', forCustomer: true, customerId: 'c1', ticketId: 't1', preview: { subdomain: 'prev' } }, opts(s.deps))
assert.deepEqual(s.calls.update, [['p10', { subdomain: 'prev', projectType: 'preview' }]])
})
test('Warnungen von assignProject und Preview-Fehler werden zusammengefuehrt', async () => {
const s = stubs()
s.deps.assignProject = async () => ({ ok: true, warnings: ['Umzug fehlgeschlagen'] })
s.deps.updateWebsiteProject = async () => { throw new Error('Gitea aus') }
const r = await completeAssignment('p13', { kind: 'website', forCustomer: true, customerId: 'c1', ticketId: 't1', preview: { subdomain: 'w' } }, opts(s.deps))
assert.equal(r.ticketId, 't1')
assert.equal(r.warnings.length, 2)
assert.equal(r.warnings[0], 'Umzug fehlgeschlagen')
assert.match(r.warnings[1], /Preview/)
assert.equal(aw.get('websiteProjects', 'p13').kind, 'website')
})
test('gleichzeitiger Doppelaufruf ⇒ genau ein Ticket und ein 409', async () => {
const s = stubs()
const before = aw.all('workorders').length
const body = { kind: 'app', forCustomer: true, customerId: 'c1', newTicket: { topic: 'race' } }
const res = await Promise.allSettled([completeAssignment('p14', body, opts(s.deps)), completeAssignment('p14', body, opts(s.deps))])
assert.equal(res.filter((x) => x.status === 'fulfilled').length, 1)
const rej = res.find((x) => x.status === 'rejected')
assert.equal(rej.reason.status, 409)
assert.equal(aw.all('workorders').length, before + 1)
})
test('forCustomer mit unbekanntem Kunden ⇒ 400', async () => {
const s = stubs()
await assert.rejects(
() => completeAssignment('p15', { kind: 'website', forCustomer: true, customerId: 'nix', newTicket: { topic: 'x' } }, opts(s.deps)),
(e) => e.status === 400
)
assert.equal(aw.get('websiteProjects', 'p15').kind, undefined)
})
test('ticketId und newTicket beide oder keins ⇒ 400', async () => {
const s = stubs()
const base = { kind: 'website', forCustomer: true, customerId: 'c1' }
for (const extra of [{ ticketId: 't1', newTicket: { topic: 'x' } }, {}]) {
await assert.rejects(() => completeAssignment('p16', { ...base, ...extra }, opts(s.deps)), (e) => e.status === 400)
}
assert.equal(aw.get('websiteProjects', 'p16').kind, undefined)
})
test('Route: ohne Staff-JWT ⇒ 401, mit JWT intern 200, bereits zugeordnet 409, unbekannt 404', async () => {
const post = (id, body, jwt) =>
fetch(`${baseUrl}/api/admin/website-projects/${id}/zuordnen`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(jwt ? { Authorization: `Bearer ${jwt}` } : {}) },
body: JSON.stringify(body),
}).then(async (r) => ({ status: r.status, body: await r.json() }))
assert.equal((await post('p11', { kind: 'other', forCustomer: false })).status, 401)
const ok = await post('p11', { kind: 'other', forCustomer: false }, 'jwt-admin')
assert.deepEqual([ok.status, ok.body], [200, { ticketId: null }])
assert.ok(aw.get('websiteProjects', 'p11').kindSetAt) // Fake-Nutzer hat weder name noch email
const again = await post('p12', { kind: 'other', forCustomer: false }, 'jwt-admin')
assert.deepEqual([again.status, again.body], [409, { error: 'bereits zugeordnet', kindSetBy: 'Max', kindSetAt: '2026-09-29T08:00:00.000Z' }])
assert.equal((await post('nix', { kind: 'other', forCustomer: false }, 'jwt-admin')).status, 404)
})
+36
View File
@@ -0,0 +1,36 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { KINDS, FEATURES, isKind, kindLabel, ticketTypeFor, servicesCatalog } from '../server/services/projectKinds.js'
test('Schlüssel und Labels in fester Reihenfolge', () => {
assert.deepEqual(KINDS.map(k => k.key), ['website', 'webapp', 'app', 'migration', 'automation', 'other'])
assert.deepEqual(KINDS.map(k => k.label), ['Website', 'Web-App', 'App', 'Migration/Umzug', 'Automation/Skript', 'Sonstiges'])
})
test('isKind', () => {
assert.equal(isKind('website'), true)
assert.equal(isKind('shop'), false)
assert.equal(isKind(''), false)
})
test('kindLabel', () => {
assert.equal(kindLabel('migration'), 'Migration/Umzug')
assert.equal(kindLabel('xyz'), '')
})
test('ticketTypeFor', () => {
assert.equal(ticketTypeFor('migration'), 'Migration')
assert.equal(ticketTypeFor('app'), 'Webpage')
})
test('Preview-Werte', () => {
const p = Object.fromEntries(KINDS.map(k => [k.key, k.preview]))
assert.deepEqual(p, { website: 'yes', webapp: 'possible', app: 'no', migration: 'no', automation: 'no', other: 'no' })
})
test('Features nur bei Website', () => {
assert.deepEqual(KINDS.find(k => k.key === 'website').features, ['analyse'])
for (const k of KINDS.filter(k => k.key !== 'website')) assert.deepEqual(k.features, [])
assert.deepEqual(FEATURES.analyse, { label: 'Website-Analyse', status: 'in_development' })
})
test('servicesCatalog und Beschreibungen', () => {
const c = servicesCatalog()
assert.equal(c.kinds, KINDS)
assert.equal(c.features, FEATURES)
for (const k of KINDS) assert.ok(k.description.length > 0)
})
+2 -1
View File
@@ -22,7 +22,8 @@ const sheet = (id, extra) => ({
// Neuestes Blatt, damit es auch in getCustomerActivity (max. 20 je Ticket) dabei ist
aw.seed('worksheets', sheet('ws1', { supporters: JSON.stringify([andrej]), $createdAt: '2026-09-30T18:00:00.000Z' }))
// Mehr als 50 Blaetter: die Gesamtzeit zaehlt trotzdem alle
for (let i = 0; i < 55; i++) aw.seed('worksheets', sheet(`wsx${i}`, { totalTime: 15 }))
// Feste, aeltere Zeitstempel: sonst waeren die Blaetter nach 30.09. 18:00 UTC neuer als ws1
for (let i = 0; i < 55; i++) aw.seed('worksheets', sheet(`wsx${i}`, { totalTime: 15, $createdAt: '2026-09-29T08:00:00.000Z' }))
test('GET /:id/work liefert totalMinutes inkl. Unterstützung über alle Blätter', async () => {
const body = await get('/api/projects/p1/work', { cookie: sessionCookie({ customerId: 'c1', appwriteUserId: 'u1' }) })
+47
View File
@@ -0,0 +1,47 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { PROJECT_KIND_ATTRIBUTES, main } from '../scripts/projektarten-setup.mjs'
function mockClient(existing = []) {
const created = []
return {
created,
async listAttributes() {
return existing.map((key) => ({ key }))
},
async createAttribute(attr) {
created.push(attr.key)
},
async waitForAttribute() {},
}
}
test('Schema: genau die 8 Attribute, alle optional', () => {
assert.deepEqual(
PROJECT_KIND_ATTRIBUTES.map((a) => a.key),
['kind', 'forCustomer', 'kindSetBy', 'kindSetAt', 'createdByGitea', 'assignMailSentAt', 'assignReminderSentAt', 'assignMailAttempts']
)
assert.ok(PROJECT_KIND_ATTRIBUTES.every((a) => a.required === false))
const byKey = Object.fromEntries(PROJECT_KIND_ATTRIBUTES.map((a) => [a.key, a]))
assert.equal(byKey.kind.size, 20)
assert.equal(byKey.kindSetBy.size, 64)
assert.equal(byKey.createdByGitea.size, 64)
assert.equal(byKey.kindSetAt.size, 40)
assert.equal(byKey.forCustomer.type, 'boolean')
assert.equal(byKey.assignMailAttempts.type, 'integer')
})
test('main legt nur fehlende Attribute an (kind existiert schon)', async () => {
const client = mockClient(['kind'])
const result = await main({ check: false, client, log: () => {} })
assert.equal(client.created.length, 7)
assert.ok(!client.created.includes('kind'))
assert.deepEqual(result.missing.length, 7)
})
test('main --check legt nichts an und meldet Fehlende', async () => {
const client = mockClient(['kind'])
const result = await main({ check: true, client, log: () => {} })
assert.equal(client.created.length, 0)
assert.equal(result.missing.length, 7)
})
+175
View File
@@ -0,0 +1,175 @@
import { test, before, after } from 'node:test'
import assert from 'node:assert/strict'
import { createHmac } from 'node:crypto'
import express from 'express'
import { startFakeGitea } from './helpers/fakeGitea.js'
import { startFakeAppwrite } from './helpers/fakeAppwrite.js'
import { mailSink } from './support/mailSink.js'
const TOKEN = 's3cret'
const NOW = new Date()
const ago = (h) => new Date(NOW.getTime() - h * 3600_000).toISOString()
let gitea, aw, server, baseUrl, setMailTransportForTests, runRepoAssignJob, sink
async function hook(event, payload) {
const body = JSON.stringify(payload)
const res = await fetch(`${baseUrl}/webhook/gitea`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Gitea-Event': event,
'X-Gitea-Signature': createHmac('sha256', TOKEN).update(body).digest('hex'),
},
body,
})
return { status: res.status, body: await res.json().catch(() => null) }
}
const created = (full, extra = {}) => ({
action: 'created',
repository: { full_name: full, name: full.split('/')[1], html_url: `${gitea.baseUrl}/${full}`, created_at: NOW.toISOString(), owner: { login: full.split('/')[0] } },
sender: { login: 'knso' },
...extra,
})
const docsOf = (full) => aw.all('websiteProjects').filter((d) => d.repoFullName === full)
before(async () => {
gitea = await startFakeGitea({ repos: [] })
aw = await startFakeAppwrite({
seed: {
websiteProjects: [],
employees: [{ $id: 'k', displayName: 'Kenso Grimm', shortcode: 'KNSO', email: 'kenso@webklar.com' }],
},
})
Object.assign(process.env, {
GITEA_BASE_URL: gitea.baseUrl, GITEA_API_TOKEN: 'test-token', GITEA_WEBHOOK_TOKEN: TOKEN,
APPWRITE_ENDPOINT: aw.endpoint, APPWRITE_API_KEY: 'test-key', PREVIEW_DEPLOY_ROOT: '', TRAEFIK_DYNAMIC_DIR: '',
})
const [{ default: routes }, mailer, job] = await Promise.all([
import('../server/routes/webhook/gitea.js'),
import('../server/services/mailer.js'),
import('../server/services/repoAssignJob.js'),
])
setMailTransportForTests = mailer.setMailTransportForTests
runRepoAssignJob = job.runRepoAssignJob
const app = express()
app.use(express.json({ verify: (req, _r, buf) => { req.rawBody = buf } }))
app.use('/webhook', routes)
server = await new Promise((r) => { const s = app.listen(0, '127.0.0.1', () => r(s)) })
baseUrl = `http://127.0.0.1:${server.address().port}`
})
after(async () => {
setMailTransportForTests?.(null)
await new Promise((r) => server.close(r))
await gitea.close()
await aw.close()
})
const freshSink = () => { sink = mailSink(); setMailTransportForTests(sink) }
test('repository/created → 1 Dokument + 1 Mail; Wiederholung → keine zweite', async () => {
freshSink()
const r = await hook('repository', created('knso/neu1'))
assert.equal(r.status, 200)
assert.equal(docsOf('knso/neu1').length, 1)
assert.equal(docsOf('knso/neu1')[0].createdByGitea, 'knso')
assert.equal(sink.sent.length, 1)
assert.match(sink.sent[0].html, /ticket\.webklar\.com\/projects\/zuordnen\//)
await hook('repository', created('knso/neu1'))
assert.equal(docsOf('knso/neu1').length, 1)
assert.equal(sink.sent.length, 1)
})
test('gleichzeitig Webhook + Job für dasselbe Repo → 1 Dokument, 1 Mail', async () => {
freshSink()
gitea.addRepo({ full_name: 'knso/race', created_at: NOW.toISOString() })
await Promise.all([hook('repository', created('knso/race')), runRepoAssignJob({ now: NOW }), hook('repository', created('knso/race'))])
assert.equal(docsOf('knso/race').length, 1)
assert.equal(sink.sent.length, 1)
})
test('repository/deleted und ohne Signatur', async () => {
freshSink()
const before = aw.all('websiteProjects').length
const r = await hook('repository', { ...created('knso/weg'), action: 'deleted' })
assert.equal(r.status, 200)
assert.equal(aw.all('websiteProjects').length, before)
assert.equal(sink.sent.length, 0)
const res = await fetch(`${baseUrl}/webhook/gitea`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Gitea-Event': 'repository' }, body: '{}' })
assert.equal(res.status, 401)
})
test('Job: 20 alte Repos → 0 Mails', async () => {
freshSink()
for (let i = 0; i < 20; i++) gitea.addRepo({ full_name: `knso/alt${i}`, created_at: ago(72) })
const c = await runRepoAssignJob({ now: NOW })
assert.equal(c.imported, 20)
assert.equal(c.mails, 0)
assert.equal(sink.sent.length, 0)
assert.ok(!docsOf('knso/alt0')[0].createdByGitea, 'alte Repos bekommen kein createdByGitea')
})
test('Job: Mail ohne vorherigen Versuch wird nachgeholt (createdByGitea, 0 Versuche)', async () => {
freshSink()
gitea.addRepo({ full_name: 'knso/nachholen', created_at: ago(2) })
await runRepoAssignJob({ now: NOW })
const [doc] = docsOf('knso/nachholen')
// Lauf abgebrochen, bevor die erste Mail versucht wurde
aw.all('websiteProjects').find((d) => d.$id === doc.$id).assignMailSentAt = ''
Object.assign(aw.all('websiteProjects').find((d) => d.$id === doc.$id), { assignMailAttempts: 0 })
freshSink()
const c = await runRepoAssignJob({ now: NOW })
assert.equal(c.retries, 1)
assert.equal(sink.sent.length, 1)
})
test('Job: neues Nutzer-Repo → Mail, Org-Repo → keine Mail', async () => {
freshSink()
gitea.addRepo({ full_name: 'knso/jobneu', created_at: ago(1) })
gitea.addRepo({ full_name: 'WEBklar/orgneu', created_at: ago(1) })
const c = await runRepoAssignJob({ now: NOW })
assert.equal(c.mails, 1)
assert.equal(sink.sent.length, 1)
assert.equal(docsOf('knso/jobneu')[0].createdByGitea, 'knso')
assert.ok(!docsOf('WEBklar/orgneu')[0].createdByGitea)
assert.equal((await runRepoAssignJob({ now: NOW })).mails, 0)
})
test('Job: Erinnerung nach 24 h genau einmal', async () => {
freshSink()
await runRepoAssignJob({ now: NOW })
assert.equal(sink.sent.length, 0)
const later = new Date(NOW.getTime() + 25 * 3600_000)
assert.equal((await runRepoAssignJob({ now: later })).reminders >= 1, true)
const n = sink.sent.length
assert.ok(n >= 1)
assert.equal((await runRepoAssignJob({ now: later })).reminders, 0)
assert.equal(sink.sent.length, n)
})
test('Job: fehlgeschlagene erste Mail wird wiederholt (< 3 Versuche)', async () => {
gitea.addRepo({ full_name: 'knso/retry', created_at: ago(1) })
setMailTransportForTests({ async sendMail() { throw new Error('smtp down') } })
const silent = console.error; console.error = () => {}
try { await runRepoAssignJob({ now: NOW }) } finally { console.error = silent }
assert.equal(docsOf('knso/retry')[0].assignMailAttempts, 1)
assert.ok(!docsOf('knso/retry')[0].assignMailSentAt)
freshSink()
const c = await runRepoAssignJob({ now: NOW })
assert.equal(c.retries, 1)
assert.equal(sink.sent.length, 1)
assert.ok(docsOf('knso/retry')[0].assignMailSentAt)
})
test('Job: Appwrite-Fehler bricht den Lauf ab, ohne zu werfen', async () => {
const silent = console.error; console.error = () => {}
const awUrl = process.env.APPWRITE_ENDPOINT
try {
const { config } = await import('../server/config.js')
const old = config.appwrite.endpoint
config.appwrite.endpoint = 'http://127.0.0.1:1/v1'
const c = await runRepoAssignJob({ now: NOW })
config.appwrite.endpoint = old
assert.ok(c.error)
} finally { console.error = silent }
assert.ok(awUrl)
})
+136
View File
@@ -0,0 +1,136 @@
import { test, before, after } from 'node:test'
import assert from 'node:assert/strict'
import { startFakeAppwrite } from './helpers/fakeAppwrite.js'
import { mailSink } from './support/mailSink.js'
let fake
let shouldSendAssignMail, dueReminder, sendRepoAssignMail
let renderMail, setMailTransportForTests, getDocument
const NOW = new Date('2026-09-30T15:00:00Z')
const hoursAgo = (h) => new Date(NOW.getTime() - h * 3600_000).toISOString()
const KENSO = { $id: 'k', displayName: 'Kenso Grimm', shortcode: 'KNSO', email: 'kenso@webklar.com', $createdAt: '2026-01-01T00:00:00.000Z' }
const projectDoc = (extra = {}) => ({
$id: 'p1',
repoFullName: 'WEBklar/amin-friseur',
giteaRepoUrl: 'https://git.webklar.com/WEBklar/amin-friseur',
...extra,
})
before(async () => {
fake = await startFakeAppwrite({
seed: {
employees: [KENSO],
websiteProjects: [projectDoc(), projectDoc({ $id: 'p2' }), projectDoc({ $id: 'p3' })],
},
})
Object.assign(process.env, { APPWRITE_ENDPOINT: fake.endpoint, APPWRITE_API_KEY: 'test-key' })
;({ shouldSendAssignMail, dueReminder, sendRepoAssignMail } = await import('../server/services/repoAssignMail.js'))
;({ renderMail } = await import('../server/services/mailTemplates.js'))
;({ setMailTransportForTests } = await import('../server/services/mailer.js'))
;({ getDocument } = await import('../server/services/appwriteAdmin.js'))
})
after(async () => {
setMailTransportForTests?.(null)
await fake.close()
})
const doc = (id) => getDocument(process.env.APPWRITE_COLLECTION_WEBSITE_PROJECTS || 'websiteProjects', id)
test('shouldSendAssignMail: nur neu angelegt, < 24 h, ohne Art, < 3 Versuche', () => {
const repo = { created_at: hoursAgo(1) }
assert.equal(shouldSendAssignMail({}, repo, { now: NOW, createdNow: true }), true)
assert.equal(shouldSendAssignMail({}, repo, { now: NOW, createdNow: false }), false)
assert.equal(shouldSendAssignMail({}, { created_at: hoursAgo(25) }, { now: NOW, createdNow: true }), false)
assert.equal(shouldSendAssignMail({ kind: 'website' }, repo, { now: NOW, createdNow: true }), false)
assert.equal(shouldSendAssignMail({ assignMailAttempts: 3 }, repo, { now: NOW, createdNow: true }), false)
assert.equal(shouldSendAssignMail({ assignMailAttempts: 2 }, repo, { now: NOW, createdNow: true }), true)
assert.equal(shouldSendAssignMail({ assignMailSentAt: hoursAgo(1) }, repo, { now: NOW, createdNow: true }), false)
assert.equal(shouldSendAssignMail({}, {}, { now: NOW, createdNow: true }), false, 'ohne created_at keine Mail')
})
test('dueReminder: genau nach 24 h, ohne Art, ohne frühere Erinnerung', () => {
const base = { assignMailSentAt: hoursAgo(25) }
assert.equal(dueReminder(base, { now: NOW }), true)
assert.equal(dueReminder({ assignMailSentAt: hoursAgo(23) }, { now: NOW }), false)
assert.equal(dueReminder({}, { now: NOW }), false)
assert.equal(dueReminder({ ...base, kind: 'app' }, { now: NOW }), false)
assert.equal(dueReminder({ ...base, assignReminderSentAt: hoursAgo(1) }, { now: NOW }), false)
assert.equal(dueReminder({ ...base, assignMailAttempts: 3 }, { now: NOW }), false)
assert.equal(dueReminder({ ...base, assignMailAttempts: 2 }, { now: NOW }), true)
})
test('T17: rendert ohne Probleme, Betreff mit und ohne Erinnerung', () => {
const values = {
betreff_vorsatz: '', vorname: 'Kenso', repo: 'WEBklar/x', repo_link: 'https://git.webklar.com/WEBklar/x',
angelegt_von: 'Kenso Grimm', angelegt_am: '30.09.2026 17:00', zuordnen_link: 'https://ticket.webklar.com/projects/zuordnen/p1',
}
const plain = renderMail('T17', values)
assert.deepEqual(plain.problems, [])
assert.equal(plain.subject, 'Neues Repo zuordnen: WEBklar/x')
assert.doesNotMatch(plain.html, /einzige Erinnerung/)
const rem = renderMail('T17', { ...values, betreff_vorsatz: 'Erinnerung: ' }, { erinnerung: true })
assert.equal(rem.subject, 'Erinnerung: Neues Repo zuordnen: WEBklar/x')
assert.match(rem.html, /einzige Erinnerung/)
})
test('sendRepoAssignMail: Mail mit exaktem Link, setzt assignMailSentAt + Versuche', async () => {
const sink = mailSink()
setMailTransportForTests(sink)
const r = await sendRepoAssignMail(projectDoc({ $id: 'p1', $createdAt: '2026-09-30T14:46:00Z' }), { giteaLogin: 'knso' })
assert.deepEqual(r, { sent: true })
assert.equal(sink.sent.length, 1)
assert.deepEqual(sink.sent[0].to.map((t) => t.address), ['kenso@webklar.com'])
assert.equal(sink.sent[0].subject, 'Neues Repo zuordnen: WEBklar/amin-friseur')
assert.match(sink.sent[0].html, /href="https:\/\/ticket\.webklar\.com\/projects\/zuordnen\/p1"/)
assert.match(sink.sent[0].html, /30\.09\.2026 16:46/)
const d = await doc('p1')
assert.ok(d.assignMailSentAt)
assert.equal(d.assignMailAttempts, 0, 'Erfolg setzt den Zähler zurück')
})
test('sendRepoAssignMail: Erinnerung setzt assignReminderSentAt', async () => {
const sink = mailSink()
setMailTransportForTests(sink)
const r = await sendRepoAssignMail(projectDoc({ $id: 'p1', assignMailAttempts: 1 }), { giteaLogin: 'knso', reminder: true })
assert.deepEqual(r, { sent: true })
assert.match(sink.sent[0].subject, /^Erinnerung: Neues Repo zuordnen/)
const d = await doc('p1')
assert.ok(d.assignReminderSentAt)
assert.equal(d.assignMailAttempts, 0)
})
test('sendRepoAssignMail: unbekannter Login → no_employee, kein Zähler', async () => {
const sink = mailSink()
setMailTransportForTests(sink)
const r = await sendRepoAssignMail(projectDoc({ $id: 'p2' }), { giteaLogin: 'fremd' })
assert.deepEqual(r, { sent: false, reason: 'no_employee' })
assert.equal(sink.sent.length, 0)
const d = await doc('p2')
assert.equal(d.assignMailAttempts, undefined)
assert.equal(d.assignMailSentAt, undefined)
})
test('sendRepoAssignMail: SMTP-Fehler zählt Versuch, setzt kein SentAt, wirft nie', async () => {
setMailTransportForTests({ sendMail: async () => { throw new Error('SMTP kaputt') } })
const r = await sendRepoAssignMail(projectDoc({ $id: 'p3', assignMailAttempts: 1 }), { giteaLogin: 'knso' })
assert.equal(r.sent, false)
const d = await doc('p3')
assert.equal(d.assignMailAttempts, 2)
assert.equal(d.assignMailSentAt, undefined)
})
test('fehlgeschlagene Erinnerung zählt hoch und endet nach 3 Versuchen', async () => {
setMailTransportForTests({ sendMail: async () => { throw new Error('SMTP kaputt') } })
let project = projectDoc({ $id: 'p2', assignMailSentAt: hoursAgo(25), assignMailAttempts: 0 })
for (let i = 1; i <= 3; i += 1) {
assert.equal(dueReminder(project, { now: NOW }), true)
const r = await sendRepoAssignMail(project, { giteaLogin: 'knso', reminder: true })
assert.equal(r.sent, false)
project = { ...project, ...(await doc('p2')) }
assert.equal(project.assignMailAttempts, i)
}
assert.equal(dueReminder(project, { now: NOW }), false)
})
+62
View File
@@ -0,0 +1,62 @@
import { test, before, after } from 'node:test'
import assert from 'node:assert/strict'
import express from 'express'
import { startFakeAppwrite } from './helpers/fakeAppwrite.js'
let aw
let server
let baseUrl
before(async () => {
aw = await startFakeAppwrite({
seed: {
websiteProjects: [
{ $id: 'p1', projectName: 'Doener', repoFullName: 'JUSN/doener', customerId: 'c1', ticketId: 't1', subdomain: 'doener', kind: 'website' },
{ $id: 'p2', projectName: 'Ohne Art', repoFullName: 'JUSN/ohne', customerId: '', ticketId: '', liveDomain: 'ohne.de', subdomain: 'ohne' },
{ $id: 'p3', projectName: 'Alt', repoFullName: 'JUSN/alt', kind: 'website', archived: true },
{ $id: 'p4', projectName: 'Vorlage', repoFullName: 'JUSN/tpl', kind: 'website', isTemplate: true },
],
workorders: [{ $id: 't1', woid: '100115', customerId: 'c1', status: 'Open' }],
customers: [{ $id: 'c1', name: 'Döner Nation' }],
},
})
Object.assign(process.env, { APPWRITE_ENDPOINT: aw.endpoint, APPWRITE_API_KEY: 'test-key' })
const { default: routes } = await import('../server/routes/admin/services.js')
const app = express()
app.use('/api/admin/services', routes)
server = await new Promise((resolve) => {
const s = app.listen(0, '127.0.0.1', () => resolve(s))
})
baseUrl = `http://127.0.0.1:${server.address().port}`
})
after(async () => {
await new Promise((resolve) => server.close(resolve))
await aw.close()
})
const get = (jwt) =>
fetch(`${baseUrl}/api/admin/services`, { headers: jwt ? { Authorization: `Bearer ${jwt}` } : {} })
test('ohne Staff-JWT ⇒ 401', async () => {
assert.equal((await get()).status, 401)
})
test('Katalog + Gruppen, Archiv/Vorlage ausgeschlossen, Namen aufgelöst', async () => {
const r = await get('jwt-admin')
assert.equal(r.status, 200)
const body = await r.json()
assert.equal(body.kinds.length, 6)
assert.equal(body.features.analyse.label, 'Website-Analyse')
assert.equal(body.groups.website.length, 1)
assert.equal(body.groups.missing.length, 1)
assert.deepEqual(body.groups.website[0], {
id: 'p1', projectName: 'Doener', repoFullName: 'JUSN/doener', customerId: 'c1',
customerName: 'Döner Nation', ticketId: 't1', woid: '100115',
previewUrl: 'https://doener.project.webklar.com', features: {},
})
assert.equal(body.groups.missing[0].previewUrl, 'https://ohne.de')
assert.equal(body.groups.missing[0].customerName, '')
const all = Object.values(body.groups).flat().map((p) => p.id)
assert.ok(!all.includes('p3') && !all.includes('p4'))
})
+97
View File
@@ -0,0 +1,97 @@
import { test, before, after } from 'node:test'
import assert from 'node:assert/strict'
import { startFakeAppwrite } from './helpers/fakeAppwrite.js'
let aw
let createTicketForProject
let deleteTicket
before(async () => {
aw = await startFakeAppwrite({
seed: {
workorders: [{ $id: 't1', woid: '100115' }, { $id: 't2', woid: '100120' }],
customers: [{ $id: 'c1', name: 'Döner Nation', location: 'Berlin' }],
},
})
Object.assign(process.env, {
APPWRITE_ENDPOINT: aw.endpoint,
APPWRITE_API_KEY: 'test-key',
})
// config.js liest die Umgebung beim Import
;({ createTicketForProject, deleteTicket } = await import('../server/services/ticketAdmin.js'))
})
after(async () => {
await aw.close()
})
test('createTicketForProject legt ein offenes Ticket mit Kundendaten und nächster WOID an', async () => {
const r = await createTicketForProject({
customerId: 'c1',
topic: 'Website – döner',
type: 'Webpage',
requestedBy: 'Max',
startDate: '2026-10-05',
details: 'Details',
})
assert.equal(r.woid, '100121')
const d = aw.get('workorders', r.$id)
assert.equal(d.topic, 'Website – döner')
assert.equal(d.type, 'Webpage')
assert.equal(d.status, 'Open')
assert.equal(d.priority, 1)
assert.equal(d.serviceType, 'Remote')
assert.equal(d.systemType, 'n/a')
assert.equal(d.customerId, 'c1')
assert.equal(d.customerName, 'Döner Nation')
assert.equal(d.customerLocation, 'Berlin')
assert.equal(d.woid, '100121')
assert.equal(d.requestedBy, 'Max')
assert.equal(d.startDate, '05.10.2026')
assert.equal(d.deadline, '05.10.2026')
assert.equal(d.details, 'Details')
assert.ok(d.createdAt)
})
test('createTicketForProject: unbekannter Kunde ⇒ Fehler mit status 400', async () => {
const before = aw.all('workorders').length
await assert.rejects(
() => createTicketForProject({ customerId: 'nix', topic: 'x', type: 'Webpage' }),
(err) => err.status === 400 && /Kunde/.test(err.message)
)
assert.equal(aw.all('workorders').length, before)
})
test('deleteTicket löscht das Ticket', async () => {
await deleteTicket('t2')
assert.equal(aw.get('workorders', 't2'), undefined)
assert.ok(aw.calls.some((c) => c.method === 'DELETE' && c.path.endsWith('/documents/t2')))
})
test('createTicketForProject: leeres Startdatum = heute (Berlin), Start und Frist gleich', async () => {
const r = await createTicketForProject(
{ customerId: 'c1', topic: 'x', type: 'Webpage', startDate: '' },
{ now: new Date('2026-12-31T23:30:00Z') } // in Berlin schon der 01.01.2027
)
const d = aw.get('workorders', r.$id)
assert.equal(d.startDate, '01.01.2027')
assert.equal(d.deadline, '01.01.2027')
})
test('createTicketForProject: ungültiges Startdatum ⇒ status 400, kein Ticket', async () => {
const n = aw.all('workorders').length
for (const bad of ['05.10.2026', '2026-13-01', '2026-02-30', 'morgen']) {
await assert.rejects(
() => createTicketForProject({ customerId: 'c1', topic: 'x', type: 'Webpage', startDate: bad }),
(err) => err.status === 400 && /Startdatum/.test(err.message)
)
}
assert.equal(aw.all('workorders').length, n)
})
test('createTicketForProject: fehlende customerId ⇒ status 400', async () => {
await assert.rejects(
() => createTicketForProject({ topic: 'x', type: 'Webpage' }),
(err) => err.status === 400
)
})