Files
Webklar-Kundenbereich/docs/superpowers/plans/2026-09-27-gitea-organisation-1-kundenportal.md
ClaudeandClaude Opus 5.5 a3faa1aff1 Umsetzungspläne: Kundenportal, Ticketsystem, Lead-Automatik
Plan 1 Kundenportal (Gitea-Grundlage, Umzug, Nachtragen, Livegang),
Plan 2 Ticketsystem (Dialog, Knöpfe), Plan 3 Lead-Automatik
(neue Lead-Repos in WEBklar über das System-Konto).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 10:21:34 +00:00

1118 lines
58 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Plan 1 – Kundenportal: System-Konto, Umzug nach WEBklar, Pushes nachtragen
> **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.
**Ziel:** Das Kundenportal arbeitet über das neutrale Konto `webklar-system` statt über knso. Es verschiebt zugewiesene Repos in die Organisation WEBklar, trägt Pushes der letzten 30 Tage als Arbeitsblätter nach und schreibt Push-Zeiten in deutscher Zeit. Danach ziehen die 6 Kundenprojekte um.
**Aufbau:**
- **Kleine Bausteine**, jeder in einer eigenen Datei unter `server/services/`:
- `slug.js`: Umlaute für Subdomains und Repo-Namen,
- `berlinTime.js`: deutsche Zeit,
- `keyedLock.js`: Sperren im Portal-Prozess,
- `giteaClient.js`: der bisherige `giteaFetch`, aus `giteaAdmin.js` herausgelöst.
- **Fachlogik:**
- `pushHistory.js` liest die Push-Historie aus den Gitea-Aktivitäten,
- `repoMove.js` erledigt den Umzug und erkennt interne Repos,
- `projectAssignment.js` bündelt Zuweisen, Umzug und Nachtragen.
- **Bestehende Dateien:**
- `gitWorksheet.js`: deutsche Zeit, keine System-Pushes, keine Doppelten,
- `giteaAdmin.js`: Import neuer Repos, Abgleich ohne Subdomains, Selbstheilung nur für gehostete Projekte,
- drei neue Admin-Routen,
- der Webhook wartet auf laufende Umzüge.
- **Zwei Skripte:** Einrichtung der Organisation (Phase 1) und Umzug der Kundenprojekte (Phase 4).
- **Tests:** Sie laufen gegen einen Gitea-Nachbau und einen Appwrite-Nachbau im Testprozess.
**Technik:**
- Node 20 in Produktion, Node 24 auf dem Host
- ESM, Express
- Gitea 1.25.2 REST (`/api/v1`)
- Appwrite-REST über `server/services/appwriteAdmin.js`
- `node --test`
**Spec:** `docs/superpowers/specs/2026-09-27-gitea-organisation-design.md`. Maßgeblich sind die Abschnitte 4, 5, 7, 9, 10, 11 und 12. Die drei Punkte „bitte prüfen“ aus Abschnitt 3 hat der Nutzer am 27.09.2026 bestätigt.
**Weitere Pläne:**
- Plan 2 baut das Ticketsystem um: `2026-09-27-gitea-organisation-2-ticketsystem.md`.
- Plan 3 baut die Lead-Automatik um: `2026-09-27-gitea-organisation-3-lead-automatik.md`.
- Plan 2 setzt voraus, dass Plan 1 live ist (Livegang, Schritt L5).
## Global Constraints
**Werte, die wörtlich gelten**
- **Organisation:** `WEBklar`. Konfiguration `GITEA_ORG`, Voreinstellung `WEBklar`.
- **System-Konto:** `webklar-system`. Konfiguration `GITEA_SYSTEM_USER`, Voreinstellung `webklar-system`. E-Mail `webklar-system@noreply.webklar.com`.
- **Schlüssel des Portals:** Name `portal`, Rechte `write:repository,write:organization,read:user`.
- **Portal-Webhook-URL:** `${config.publicBaseUrl}/webhook/gitea`, in Produktion `https://project.webklar.com/webhook/gitea`. Ein Einzel-Webhook gilt als Portal-Webhook, wenn seine URL mit genau diesem Text beginnt.
- **Organisations-Webhook:** `type: "gitea"`, `active: true`, `events: ["push"]`, `branch_filter: "*"`, `config.content_type: "json"`, `config.secret` = Wert von `GITEA_WEBHOOK_TOKEN`.
- **Nachtragen:** 30 Tage. Konfiguration `GITEA_BACKFILL_DAYS`, Voreinstellung 30.
- **Alte System-Commits:** Beim Nachtragen wird ein Push übersprungen, wenn die erste Zeile jeder seiner Commit-Nachrichten in `SYSTEM_COMMIT_MESSAGES` steht, also `Update preview config from ticket system` oder `Automatisch erstellte Preview-Seite`. Solche Commits hat früher das System mit Kensos Schlüssel geschrieben, und sie tragen deshalb knso als Pusher.
- **Wartezeit des Webhooks auf einen Umzug:** 10000 ms.
- **`GITEA_KEEP_IN_PLACE`**, Voreinstellung: `ANDJ/Emailsorter,ANDJ/Steuerungstechnik,JUSN/tickte-system,JUSN/Webklar,knso/eship,knso/Webklar-Kundenbereich`.
- **Zeit:** Europe/Berlin. Datum `dd.mm.yyyy`, Uhrzeit `hhmm`, also dasselbe Format wie heute im Arbeitsblatt.
- **Groß- und Kleinschreibung:** Besitzer- und Repo-Namen werden immer ohne Rücksicht darauf verglichen, denn Gitea behandelt sie ebenso.
**Warnungen, wörtlich**
- `Internes Repo, bleibt wo es ist.`
- `` `In WEBklar gibt es schon ein Repo namens ${name}. Bitte in Gitea umbenennen und dann ‚Pushes nachtragen‘ klicken.` ``
- `` `Gitea ist gerade nicht erreichbar (${grund}). Bitte später ‚Pushes nachtragen‘ klicken.` ``
- `` `Das Repo liegt jetzt in WEBklar, aber die Projektdaten konnten nicht angepasst werden (${grund}). Bitte ‚Pushes nachtragen‘ klicken.` ``
**Was sich nicht ändern darf**
- der Wert von `GITEA_WEBHOOK_TOKEN`,
- `GITEA_TEMPLATE_OWNER`,
- die Deploy-Container,
- vorhandene Arbeitsblätter,
- das Verhalten für Kunden im Portal.
**Tests**
- Tests erreichen nie Produktion. `GITEA_BASE_URL` und `APPWRITE_ENDPOINT` zeigen in Tests auf Nachbauten unter `127.0.0.1`. `PREVIEW_DEPLOY_ROOT` und `TRAEFIK_DYNAMIC_DIR` sind leer.
- `npm test` muss unter Node 24 und unter Node 20 grün sein.
**Git und Produktion**
- Gearbeitet wird im Worktree `/home/webklar/apps/kundenbereich.webklar.com/dev-gitea-org` auf dem Branch `feature/gitea-organisation`, ohne Upstream.
- Niemals `git add -A`, denn der `node_modules`-Symlink ist untracked.
- Ein Push nach `main` geht sofort live und erfolgt nur mit Freigabe des Nutzers.
- Produktionsschritte (Gitea-Befehlszeile, `.env`, Compose) stehen nur im Abschnitt „Livegang“, und jeder braucht das OK des Nutzers. Blockiert die Rechteprüfung einen Schritt, bekommt der Nutzer die fertigen Befehle.
## Review Focus
1. **Groß- und Kleinschreibung.** Gitea liefert „JUSN/D-ner-Nation“, jemand tippt „webklar/x“, oder die Liste enthält „andj/steuerungstechnik“. Die Erkennung von WEBklar-Repos und internen Repos darf daran nicht scheitern. Test: Task 5, „Erkennung ohne Groß-/Kleinschreibung“.
2. **Ein Push mit mehreren Commits.** Beim Nachtragen muss `commitSha` der HeadCommit sein, also derselbe Wert wie `after` im Webhook. Sonst entsteht bei einer späteren Zustellung desselben Pushes ein zweites Arbeitsblatt. Test: Task 4, „HeadCommit bestimmt commitSha“.
3. **Ein Doppelklick auf „Zuweisen“.** Das ergibt genau eine Übertragung und keine doppelten Arbeitsblätter. Test: Task 6, „Doppelklick: ein Umzug, keine doppelten Arbeitsblätter“.
4. **Ein Repo wurde schon von Hand in Gitea verschoben.** Der Projekt-Eintrag wird über die Weiterleitung korrigiert, ohne zweite Übertragung. Test: Task 5, „Weiterleitung korrigiert Projektdaten“.
5. **Ein Push mit dem alten Repo-Namen kommt nach dem Umzug an.** Dabei entsteht kein zweiter Projekt-Eintrag, und das Arbeitsblatt landet im richtigen Ticket. Test: Task 8, „Webhook nach Umzug mit altem Namen“.
---
## Dateien
| Datei | Aufgabe |
|---|---|
| `server/services/slug.js` (neu) | `slugify` mit Umlauten, Apostrophen und Akzenten |
| `server/services/berlinTime.js` (neu) | `berlinDateTime` |
| `server/services/keyedLock.js` (neu) | `withLocks` |
| `server/services/giteaClient.js` (neu) | `giteaFetch`, `splitRepo` (aus `giteaAdmin.js` verschoben) |
| `server/services/pushHistory.js` (neu) | `pushesFromFeed`, `listRecentPushes` |
| `server/services/repoMove.js` (neu) | `WARN`, `isOrgRepo`, `repoLockKey`, `isInternalRepo`, `moveRepoToOrg`, `currentRepoFullName` |
| `server/services/projectAssignment.js` (neu) | `backfillPushes`, `reconcileProject`, `assignProject` |
| `server/config.js` | `gitea.org`, `gitea.systemUser`, `gitea.keepInPlace`, `gitea.backfillDays` |
| `server/services/gitWorksheet.js` | deutsche Zeit, `pushedAt`, `isSystemPusher`, Sperre |
| `server/services/giteaAdmin.js` | nutzt `giteaClient.js` und `slug.js`; `importNewRepos`, `isHostedProject`; Abgleich ohne Subdomain |
| `server/services/previewDeploy.js` | `previewWorkDir` exportiert und in `runDeploy` genutzt |
| `server/routes/admin/gitea.js` | `POST /import-repos` |
| `server/routes/admin/websiteProjects.js` | `POST /:id/assign`, `POST /:id/pushes` |
| `server/routes/webhook/gitea.js` | Sperre und `currentRepoFullName` |
| `scripts/gitea-organisation-setup.mjs` (neu) | Organisation, Team, Organisations-Webhook |
| `scripts/gitea-organisation-migrate.mjs` (neu) | Umzug der 6 Kundenprojekte |
| `test/helpers/fakeAppwrite.js`, `test/helpers/fakeGitea.js` (neu) | Nachbauten; liegen nicht unter `test/*.test.js` und laufen daher nicht als Tests |
| `test/*.test.js` (neu, je Task) | siehe Tasks |
---
### Task 1: Kleine Bausteine – slugify, deutsche Zeit, Sperren
**Files:**
- Create: `server/services/slug.js`, `server/services/berlinTime.js`, `server/services/keyedLock.js`
- Test: `test/slug.test.js`, `test/berlinTime.test.js`, `test/keyedLock.test.js`
**Interfaces:**
- Produces:
- `slugify(value: unknown): string`
- `berlinDateTime(date: Date = new Date()): { date: string, time: string }`
- `withLocks<T>(keys: string[], fn: () => Promise<T>, { waitMs = Infinity } = {}): Promise<T>`
- [ ] **Step 1: Write the failing tests**
```js
// test/slug.test.js
test('Umlaute', () => {
assert.equal(slugify('Döner Nation'), 'doener-nation')
assert.equal(slugify('Größe Überall Ärger'), 'groesse-ueberall-aerger')
})
test('Apostrophe entfallen', () => {
assert.equal(slugify("Emma's Schatzkiste"), 'emmas-schatzkiste')
assert.equal(slugify('Emma’s'), 'emmas')
})
test('Akzente', () => assert.equal(slugify('Café Élan'), 'cafe-elan'))
test('Sonderzeichen, Ränder, Länge', () => {
assert.equal(slugify('--A&B--'), 'a-b')
assert.equal(slugify('x'.repeat(150)).length, 100)
assert.equal(slugify(null), '')
})
// test/berlinTime.test.js
test('Sommerzeit', () => assert.deepEqual(berlinDateTime(new Date('2026-09-27T14:46:34Z')), { date: '27.09.2026', time: '1646' }))
test('Winterzeit', () => assert.deepEqual(berlinDateTime(new Date('2026-01-15T10:05:00Z')), { date: '15.01.2026', time: '1105' }))
test('nach Mitternacht UTC', () => assert.deepEqual(berlinDateTime(new Date('2026-09-27T22:30:00Z')), { date: '28.09.2026', time: '0030' }))
// test/keyedLock.test.js – Hilfe: const log = []; const job = (name, ms) => async () => { log.push(name + ':start'); await sleep(ms); log.push(name + ':end') }
test('gleicher Schlüssel läuft nacheinander', async () => {
await Promise.all([withLocks(['a'], job('1', 50)), withLocks(['a'], job('2', 10))])
assert.deepEqual(log, ['1:start', '1:end', '2:start', '2:end'])
})
test('verschiedene Schlüssel laufen parallel', async () => {
await Promise.all([withLocks(['a'], job('1', 50)), withLocks(['b'], job('2', 10))])
assert.deepEqual(log.slice(0, 2), ['1:start', '2:start'])
})
test('mehrere Schlüssel sperren alle', async () => {
await Promise.all([withLocks(['a', 'b'], job('1', 50)), withLocks(['b'], job('2', 10))])
assert.deepEqual(log, ['1:start', '1:end', '2:start', '2:end'])
})
test('waitMs begrenzt das Warten', async () => {
const t0 = Date.now()
let started
await Promise.all([withLocks(['a'], job('1', 300)), withLocks(['a'], async () => { started = Date.now() - t0 }, { waitMs: 50 })])
assert.ok(started >= 45 && started < 250, `gestartet nach ${started} ms`)
})
test('Fehler geben die Sperre frei', async () => {
await assert.rejects(withLocks(['a'], async () => { throw new Error('x') }))
assert.equal(await withLocks(['a'], async () => 42), 42)
})
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `npm test`
Expected: FAIL. The three new test files cannot import their modules.
- [ ] **Step 3: Implement the three modules**
- **`slugify`:**
1. Lowercase the input.
2. Map ä→ae, ö→oe, ü→ue, ß→ss.
3. Remove accents with `normalize('NFD')` followed by removing `\p{M}`.
4. Remove `'`, `’`, `‘` and `` ` ``.
5. Turn `[^a-z0-9-]+` into `-`, strip leading and trailing `-`, then `slice(0, 100)`.
- **`berlinDateTime`:** Use `Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).formatToParts(date)` and assemble `dd.mm.yyyy` and `hhmm` from the parts.
- **`withLocks`:** Keep one promise chain per key in a module-level `Map`.
1. Wait on the current tails of all keys with `Promise.race([Promise.allSettled(tails), sleep(waitMs)])`.
2. Run `fn`.
3. Put a new tail for every key that resolves in `finally`.
4. Delete a key only when its tail is still the stored one.
- [ ] **Step 4: Run the tests to verify they pass**
Run: `npm test`
Expected: PASS for all tests, including the old ones.
- [ ] **Step 5: Commit**
```bash
git add server/services/slug.js server/services/berlinTime.js server/services/keyedLock.js test/slug.test.js test/berlinTime.test.js test/keyedLock.test.js
git commit -m "Bausteine: slugify mit Umlauten, deutsche Zeit, Sperren"
```
---
### Task 2: Gitea-Client herauslösen, Konfiguration, Nachbauten
**Files:**
- Create: `server/services/giteaClient.js`, `test/helpers/fakeGitea.js`, `test/helpers/fakeAppwrite.js`
- Modify: `server/services/giteaAdmin.js` (remove the local `giteaHeaders`/`giteaFetch`/`slugify`; import from `giteaClient.js` and `slug.js`), `server/config.js` (`gitea` block)
- Test: `test/giteaClient.test.js`
**Interfaces:**
- Consumes: `slugify` (Task 1)
- Produces:
- `giteaFetch(path: string, options?: RequestInit): Promise<any>`. Same behaviour as today: it adds `Authorization: token …`, throws an `Error` with `.status`, and turns a 409 "same name already exists" into the German message.
- `splitRepo(fullName: string): [string, string]`
- `config.gitea.org`, `config.gitea.systemUser`, `config.gitea.keepInPlace: string[]`, `config.gitea.backfillDays: number`
- **`startFakeGitea(options)`** → `{ baseUrl, calls, repo(fullName), addRepo(repo), hooksOf(fullName), setDown(bool), setTransferDelay(ms), close() }`.
- Options:
- `repos: [{ full_name, archived?, private?, description?, default_branch? }]`
- `hooks: { [fullName]: [{ id, url }] }`
- `feeds: { [fullName]: entry[] }`, newest first
- `contents: { [fullName + ':' + path]: string }`
- `orgs: string[]`
- `user = 'webklar-system'`
- Routes, all under `/api/v1`:
- `GET /user`
- `GET /repos/search` (`{ ok, data }`, with `limit`/`page`)
- `GET` and `PATCH /repos/:o/:r`. For a moved repo it answers `307` to the new path, like Gitea.
- `POST /repos/:o/:r/transfer`. On success `202` with the new repo, and hooks, feeds and contents move along. If the target exists, `422 { message: 'repository already exists …' }`.
- `GET /repos/:o/:r/hooks`, `DELETE /repos/:o/:r/hooks/:id`
- `GET /repos/:o/:r/activities/feeds?limit&page`
- `GET/POST/PUT /repos/:o/:r/contents/:path`
- `GET /orgs/:org`, `POST /orgs`
- `GET /orgs/:org/teams`, which returns Owners with id 1 for created orgs
- `GET /teams/:id/members`, `PUT /teams/:id/members/:user`
- `GET/POST /orgs/:org/hooks`
- Names are case-insensitive. `setDown(true)` destroys every connection.
- **`startFakeAppwrite({ seed })`** → `{ endpoint, all(col), get(col, id), calls, close() }`.
- `endpoint` ends in `/v1`.
- Documents under `/databases/:db/collections/:col/documents`: list with `queries[]` (`equal`, `limit`, `orderAsc`, `orderDesc`, `greaterThan`), create from `{ documentId, data }`, get, patch from `{ data }`, delete.
- `GET /account`: `X-Appwrite-JWT: jwt-admin` returns `{ $id: 'staff1', labels: ['admin'] }`, anything else returns 401.
- [ ] **Step 1: Write the failing test** `test/giteaClient.test.js`
It starts `startFakeGitea({ repos: [{ full_name: 'JUSN/D-ner-Nation' }] })` and sets `GITEA_BASE_URL=<baseUrl>` and `GITEA_API_TOKEN=test-token` before the dynamic import.
```js
test('sendet den Token', async () => {
await giteaFetch('/repos/JUSN/D-ner-Nation')
assert.equal(fake.calls.at(-1).headers.authorization, 'token test-token')
})
test('404 hat status', async () => {
await assert.rejects(giteaFetch('/repos/x/y'), (e) => e.status === 404)
})
test('Voreinstellungen der Konfiguration', () => {
assert.equal(config.gitea.org, 'WEBklar')
assert.equal(config.gitea.systemUser, 'webklar-system')
assert.equal(config.gitea.backfillDays, 30)
assert.ok(config.gitea.keepInPlace.includes('JUSN/tickte-system'))
assert.equal(config.gitea.keepInPlace.length, 6)
})
```
- [ ] **Step 2: Run to verify it fails**
Run: `node --test test/giteaClient.test.js`
Expected: FAIL, because `giteaClient.js` is missing.
- [ ] **Step 3: Implement**
- Move `giteaHeaders` and `giteaFetch` into `giteaClient.js` unchanged, plus `splitRepo`. `giteaAdmin.js` imports them from there.
- Replace the local `slugify` in `giteaAdmin.js` with the one from `slug.js`.
- Build the fakes as specified under Interfaces.
- In `config.js`, add inside `gitea`:
- `org: process.env.GITEA_ORG || 'WEBklar'`
- `systemUser: process.env.GITEA_SYSTEM_USER || 'webklar-system'`
- `keepInPlace`: parsed from `GITEA_KEEP_IN_PLACE` (comma-separated, trimmed), defaulting to the list in Global Constraints
- `backfillDays: Number(process.env.GITEA_BACKFILL_DAYS) || 30`
- [ ] **Step 4: Run all tests**
Run: `npm test`
Expected: PASS. The old tests stay green; `giteaAdmin.js` has only been refactored.
- [ ] **Step 5: Commit**
```bash
git add server/services/giteaClient.js server/services/giteaAdmin.js server/config.js test/helpers/fakeGitea.js test/helpers/fakeAppwrite.js test/giteaClient.test.js
git commit -m "Gitea-Client herausgelöst, Konfiguration für Organisation und System-Konto, Test-Nachbauten"
```
---
### Task 3: Push-Arbeitsblätter – deutsche Zeit, keine System-Pushes, keine Doppelten
**Files:**
- Modify: `server/services/gitWorksheet.js`
- Test: `test/gitWorksheet.test.js`
**Interfaces:**
- Consumes: `berlinDateTime`, `withLocks` (Task 1); `config.gitea.systemUser` (Task 2)
- Produces:
- `isSystemPusher(pusher): boolean`. The name is `pusher.username || pusher.login`, compared with `config.gitea.systemUser` case-insensitively.
- `createGitPushWorksheet({ ticketId, repoFullName, branch, commits = [], pusher, commitSha, pushedAt = new Date() })` returns one of:
- `{ created: true, wsid, worksheetId }`
- `{ skipped: true, reason: 'system_pusher' | 'duplicate_commit' }`
- `null`
- [ ] **Step 1: Write the failing tests**
The fake Appwrite gets the seed `workorders: [{ $id: 't1', woid: '100115', status: 'Assigned' }]` and `worksheets: [{ $id: 'w0', wsid: '100077', woid: '100001', details: '' }]`. The base call is:
```js
const base = { ticketId: 't1', repoFullName: 'WEBklar/D-ner-Nation', branch: 'main',
commits: [{ id: '26bc22ecf17e2e5a5a8794dcb4cf3892f802c51d', message: 'Döner Nation Website hinzufügen', author: { name: 'JUSN' } }],
pusher: { login: 'JUSN', username: 'JUSN', full_name: '' }, commitSha: '26bc22ecf17e2e5a5a8794dcb4cf3892f802c51d' }
```
```js
test('Push-Zeit in deutscher Zeit', async () => {
const r = await createGitPushWorksheet({ ...base, pushedAt: new Date('2026-09-27T14:46:34Z') })
assert.equal(r.created, true)
const ws = fake.get('worksheets', r.worksheetId)
assert.equal(ws.wsid, '100078')
assert.equal(ws.employeeName, 'JUSN')
assert.deepEqual([ws.startDate, ws.startTime, ws.endDate, ws.endTime], ['27.09.2026', '', '27.09.2026', '1646'])
assert.equal(ws.totalTime, 0); assert.equal(ws.isComment, true); assert.equal(ws.serviceType, 'GIT')
assert.match(ws.details, /Commit: 26bc22e/)
})
test('zweiter Aufruf: Duplikat', async () => {
assert.deepEqual(await createGitPushWorksheet({ ...base }), { skipped: true, reason: 'duplicate_commit' })
})
test('System-Konto erzeugt kein Arbeitsblatt', async () => {
const before = fake.all('worksheets').length
for (const name of ['webklar-system', 'WEBKLAR-SYSTEM']) {
const r = await createGitPushWorksheet({ ...base, commitSha: 'aaaaaaa1', pusher: { login: name, username: name, full_name: '' } })
assert.deepEqual(r, { skipped: true, reason: 'system_pusher' })
}
assert.equal(fake.all('worksheets').length, before)
})
test('gleichzeitige Zustellung ergibt ein Arbeitsblatt', async () => {
const args = { ...base, commitSha: 'bbbbbbb2' }
const results = await Promise.all([createGitPushWorksheet(args), createGitPushWorksheet(args)])
assert.equal(results.filter((r) => r?.created).length, 1)
assert.equal(fake.all('worksheets').filter((w) => w.details.includes('Commit: bbbbbbb')).length, 1)
})
```
- [ ] **Step 2: Run to verify it fails**
Run: `node --test test/gitWorksheet.test.js`
Expected: FAIL. `endTime` is in UTC, and the system pusher still gets a worksheet.
- [ ] **Step 3: Implement**
- Replace `formatToday` and `formatTimeNow` with `berlinDateTime(pushedAt)`. It sets `startDate`, `endDate` and `endTime`; `startTime` stays `''`.
- Check `isSystemPusher(pusher)` before loading the ticket.
- Run the existence check and the create step inside `withLocks([`ws:${woid}:${commitSha.slice(0, 7)}`], …)`.
- The rest stays unchanged, including the field list and `DETAILS_MAX_LENGTH`.
- [ ] **Step 4: Run all tests**
Run: `npm test`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add server/services/gitWorksheet.js test/gitWorksheet.test.js
git commit -m "Push-Arbeitsblätter: deutsche Zeit, keine System-Pushes, keine Doppelten"
```
---
### Task 4: Push-Historie aus den Gitea-Aktivitäten
**Files:**
- Create: `server/services/pushHistory.js`
- Test: `test/pushHistory.test.js`
**Interfaces:**
- Consumes: `giteaFetch`, `splitRepo`, `config.gitea.backfillDays` (Task 2)
- Produces:
- A `Push` has the shape `{ branch, commitSha, pushedAt: Date, pusher: { login, username, full_name }, commits: [{ id, message, author: { name } }] }`.
- `pushesFromFeed(feed: object[], { since: Date }): Push[]`, oldest first.
- `listRecentPushes(repoFullName, { days = config.gitea.backfillDays, now = new Date() } = {}): Promise<Push[]>`.
- [ ] **Step 1: Write the failing tests**
Feed entries look like Gitea 1.25: `{ op_type, act_user: { login, full_name }, ref_name, created, content }`. For `commit_repo`, `content` is a JSON string `{"Commits":[{"Sha1","Message","AuthorName",…}],"HeadCommit":{"Sha1",…}}`.
```js
test('nur commit_repo mit Commits', () => {
const feed = [pushEntry({ sha: 'c1', at: '2026-09-27T14:46:34Z' }), { ...pushEntry({ at: '2026-09-27T14:46:33Z' }), content: '' }, { op_type: 'create_repo', created: '2026-09-27T14:02:46Z', act_user: { login: 'JUSN' } }]
assert.equal(pushesFromFeed(feed, { since: new Date('2026-09-01') }).length, 1)
})
test('Felder wie beim Webhook', () => {
const [p] = pushesFromFeed([pushEntry({ sha: 'c1', user: 'JUSN', at: '2026-09-27T14:46:34Z', msg: 'Döner Nation Website hinzufügen' })], { since: new Date('2026-09-01') })
assert.deepEqual(p, { branch: 'main', commitSha: 'c1', pushedAt: new Date('2026-09-27T14:46:34Z'),
pusher: { login: 'JUSN', username: 'JUSN', full_name: '' },
commits: [{ id: 'c1', message: 'Döner Nation Website hinzufügen', author: { name: 'JUSN' } }] })
})
test('HeadCommit bestimmt commitSha', () => {
const [p] = pushesFromFeed([pushEntry({ commits: ['c2', 'c1'], head: 'c2' })], { since: new Date(0) })
assert.equal(p.commitSha, 'c2'); assert.equal(p.commits.length, 2)
})
test('ohne HeadCommit gilt der erste Commit', () => {
const [p] = pushesFromFeed([pushEntry({ commits: ['c2', 'c1'], head: null })], { since: new Date(0) })
assert.equal(p.commitSha, 'c2')
})
test('Anzeigename wird übernommen', () => {
const [p] = pushesFromFeed([pushEntry({ user: 'knso', fullName: 'Kenso Grimm' })], { since: new Date(0) })
assert.equal(p.pusher.full_name, 'Kenso Grimm')
})
test('älteste zuerst und 30-Tage-Grenze', () => {
const now = new Date('2026-09-27T12:00:00Z')
const feed = [daysAgo(now, 1, 'n1'), daysAgo(now, 2, 'n2'), daysAgo(now, 31, 'alt')]
assert.deepEqual(pushesFromFeed(feed, { since: new Date(now - 30 * 864e5) }).map((p) => p.commitSha), ['n2', 'n1'])
})
test('listRecentPushes blättert nur so weit wie nötig', async () => {
// 120 Einträge, 3 pro Tag über 40 Tage, neueste zuerst; limit 50
const r = await listRecentPushes('knso/viel', { now })
assert.equal(r.length, 90)
assert.equal(fake.calls.filter((c) => c.path.includes('/activities/feeds')).length, 2)
})
```
`pushEntry`, `daysAgo` and the 120-entry feed are small builders in the test file.
- [ ] **Step 2: Run to verify it fails**
Run: `node --test test/pushHistory.test.js`
Expected: FAIL, because the module is missing.
- [ ] **Step 3: Implement**
**`pushesFromFeed`:**
1. Keep only entries with `op_type === 'commit_repo'` and `new Date(created) >= since` whose parsed `content` has a non-empty `Commits` array. If `JSON.parse` fails, skip the entry.
2. `branch` is `ref_name` without `refs/heads/`.
3. `commitSha` is `HeadCommit?.Sha1 || Commits[0].Sha1`.
4. The commits keep the order of the feed.
5. The pusher is `{ login: act_user.login, username: act_user.login, full_name: act_user.full_name || '' }`.
6. Sort the result by `pushedAt` in ascending order.
**`listRecentPushes`:**
1. Fetch `GET /repos/{o}/{r}/activities/feeds?limit=50&page=N` starting with `N=1`.
2. Stop at an empty page, at a page with fewer than 50 entries, or when the oldest `created` of the page is older than `since = now − days`.
3. Pass everything to `pushesFromFeed`.
- [ ] **Step 4: Run all tests**
Run: `npm test`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add server/services/pushHistory.js test/pushHistory.test.js
git commit -m "Push-Historie aus den Gitea-Aktivitäten lesen"
```
---
### Task 5: Umzug nach WEBklar
**Files:**
- Create: `server/services/repoMove.js`
- Modify: `server/services/previewDeploy.js`. Export `previewWorkDir(repoFullName)`, which returns `path.join(process.cwd(), 'preview-data', repoFullName.replace(/\//g, '_'))`, and use it in `runDeploy`.
- Test: `test/repoMove.test.js`
**Interfaces:**
- Consumes: `giteaFetch`, `splitRepo`, `config.gitea.*` (Task 2); `withLocks` (Task 1); `listDocuments`, `updateDocument`, `Query` (`appwriteAdmin.js`); `invalidateProjectCache` (`projectInsights.js`); `previewWorkDir`
- Produces:
- `WARN = { internal, nameTaken(name), giteaDown(reason), docNotUpdated(reason) }`, using the exact texts from Global Constraints.
- `isOrgRepo(repoFullName): boolean`
- `repoLockKey(repoFullName): string`, which returns `'repo:' + repoFullName.toLowerCase()`.
- `isInternalRepo(repoFullName): Promise<boolean>`. It is true if the repo is in `keepInPlace` (case-insensitive) or has a hook whose URL does not start with the portal webhook URL.
- `moveRepoToOrg(project): Promise<{ moved: { from, to } | null, warnings: string[] }>`
- `currentRepoFullName(repoFullName): Promise<string>`
- [ ] **Step 1: Write the failing tests**
Setup:
- **Fake Gitea:**
- repos `JUSN/D-ner-Nation`, `knso/emmas-schatzkiste`, `ANDJ/Steuerungstechnik`, `knso/hytail-ark`, `WEBklar/schon-da`, `JUSN/schon-da`, `WEBklar/von-hand`, `ANDJ/unberuehrt`,
- the redirect `knso/von-hand` → `WEBklar/von-hand`, which is already moved in the fake,
- hooks `knso/emmas-schatzkiste: [{ id: 110, url: 'https://project.webklar.com/webhook/gitea' }]` and `knso/hytail-ark: [{ id: 7, url: 'https://webklar.com/webhook/hytail-ark/deploy' }]`.
- **Fake Appwrite:** one `websiteProjects` document per repo, with `repoFullName` as it is stored today. For von-hand that is still `knso/von-hand`.
- **Working directory:** `process.chdir(tmp)` before the tests.
```js
test('verschiebt Repo aus eigenem Konto', async () => {
await fs.mkdir(previewWorkDir('JUSN/D-ner-Nation'), { recursive: true })
const r = await moveRepoToOrg(doc('JUSN/D-ner-Nation'))
assert.deepEqual(r, { moved: { from: 'JUSN/D-ner-Nation', to: 'WEBklar/D-ner-Nation' }, warnings: [] })
assert.deepEqual(gitea.calls.find((c) => c.path.endsWith('/transfer')).body, { new_owner: 'WEBklar' })
const d = aw.get('websiteProjects', doc('JUSN/D-ner-Nation').$id)
assert.equal(d.repoFullName, 'WEBklar/D-ner-Nation')
assert.equal(d.giteaRepoUrl, `${gitea.baseUrl}/WEBklar/D-ner-Nation`)
assert.equal(d.giteaRepoName, 'D-ner-Nation')
await assert.rejects(fs.access(previewWorkDir('JUSN/D-ner-Nation')))
})
test('löscht den alten Portal-Webhook', async () => {
await moveRepoToOrg(doc('knso/emmas-schatzkiste'))
assert.deepEqual(gitea.hooksOf('WEBklar/emmas-schatzkiste'), [])
})
test('schon in WEBklar: nichts passiert', async () => {
assert.deepEqual(await moveRepoToOrg(doc('WEBklar/schon-da')), { moved: null, warnings: [] })
})
test('internes Repo per Liste und per fremdem Webhook', async () => {
for (const name of ['ANDJ/Steuerungstechnik', 'knso/hytail-ark']) {
assert.deepEqual(await moveRepoToOrg(doc(name)), { moved: null, warnings: ['Internes Repo, bleibt wo es ist.'] })
}
assert.equal(gitea.calls.filter((c) => c.path.endsWith('/transfer') && /steuerung|hytail/i.test(c.path)).length, 0)
})
test('Name existiert in WEBklar', async () => {
const r = await moveRepoToOrg(doc('JUSN/schon-da'))
assert.deepEqual(r, { moved: null, warnings: ['In WEBklar gibt es schon ein Repo namens schon-da. Bitte in Gitea umbenennen und dann ‚Pushes nachtragen‘ klicken.'] })
})
test('Weiterleitung korrigiert Projektdaten', async () => {
const r = await moveRepoToOrg(doc('knso/von-hand'))
assert.deepEqual(r.moved, { from: 'knso/von-hand', to: 'WEBklar/von-hand' })
assert.equal(gitea.calls.filter((c) => c.path.endsWith('/transfer') && c.path.includes('von-hand')).length, 0)
assert.equal(aw.get('websiteProjects', doc('knso/von-hand').$id).repoFullName, 'WEBklar/von-hand')
})
test('Erkennung ohne Groß-/Kleinschreibung', async () => {
assert.equal(isOrgRepo('webklar/x'), true)
assert.equal(await isInternalRepo('andj/steuerungstechnik'), true)
})
test('currentRepoFullName', async () => {
assert.equal(await currentRepoFullName('WEBklar/D-ner-Nation'), 'WEBklar/D-ner-Nation') // Eintrag vorhanden
assert.equal(await currentRepoFullName('JUSN/D-ner-Nation'), 'WEBklar/D-ner-Nation') // kein Eintrag, Weiterleitung
})
test('Gitea nicht erreichbar', async () => {
gitea.setDown(true)
const r = await moveRepoToOrg(doc('ANDJ/unberuehrt'))
gitea.setDown(false)
assert.equal(r.moved, null)
assert.match(r.warnings[0], /^Gitea ist gerade nicht erreichbar \(.+\)\. Bitte später ‚Pushes nachtragen‘ klicken\.$/)
})
```
`doc(name)` returns the stored document of that repo.
- [ ] **Step 2: Run to verify it fails**
Run: `node --test test/repoMove.test.js`
Expected: FAIL, because the module is missing.
- [ ] **Step 3: Implement `moveRepoToOrg(project)` in this order**
1. Let `from = project.repoFullName`. If `isOrgRepo(from)`, return `{ moved: null, warnings: [] }`.
2. `GET /repos/{from}`. `fetch` follows Gitea's 307 redirect. If the answer has `full_name !== from`, update the document (step 6 without the transfer) and return `moved: { from, to: full_name }`.
3. If `isInternalRepo(from)`, return the `internal` warning.
4. Look at `GET /repos/{org}/{name}`. On 200, return `nameTaken(name)`; on 404, continue.
5. Inside `withLocks([repoLockKey(from), repoLockKey(to)])`, `POST /repos/{from}/transfer` with `{ new_owner: config.gitea.org }`. `to` is the `full_name` from the response.
6. Call `updateDocument(websiteProjects, project.$id, { repoFullName: to, giteaRepoUrl: `${config.gitea.baseUrl}/${to}`, giteaRepoName: name, updatedAt })`. If this fails, return `moved` with `docNotUpdated(err.message)`.
7. Call `invalidateProjectCache(from)` and `invalidateProjectCache(to)`.
8. For every hook of `to` whose URL starts with the portal webhook URL, send `DELETE /repos/{to}/hooks/{id}`. Errors only get logged.
9. `fs.rm(previewWorkDir(from), { recursive: true, force: true })`.
Any network error (no `.status`) or 5xx from Gitea in steps 2–5 returns `giteaDown(err.message)`.
`currentRepoFullName(name)`:
1. If a `websiteProjects` document with `repoFullName === name` exists, return `name`.
2. Otherwise return `(await giteaFetch(`/repos/${name}`)).full_name`.
3. If that fails, return `name`.
- [ ] **Step 4: Run all tests**
Run: `npm test`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add server/services/repoMove.js server/services/previewDeploy.js test/repoMove.test.js
git commit -m "Umzug zugewiesener Repos nach WEBklar"
```
---
### Task 6: Zuweisen, Umzug nachholen, Pushes nachtragen
**Files:**
- Create: `server/services/projectAssignment.js`
- Test: `test/projectAssignment.test.js`
**Interfaces:**
- Consumes:
- `moveRepoToOrg`, `WARN` (Task 5)
- `listRecentPushes` (Task 4)
- `createGitPushWorksheet`, `isSystemPusher` (Task 3)
- `withLocks` (Task 1)
- `ensureProjectTicket` (`ticketAdmin.js`)
- `getDocument`, `updateDocument` (`appwriteAdmin.js`)
- Produces:
- `backfillPushes(project, { now = new Date() } = {}): Promise<{ backfilled: number, warnings: string[] }>`
- `reconcileProject(projectId, { now } = {}): Promise<{ project, moved, backfilled, warnings }>`. It runs under `withLocks(['project:' + projectId])`.
- `assignProject(projectId, { customerId = '', ticketId = '' }, { now } = {}): Promise<{ ok: true, project, moved, backfilled, warnings }>`. It throws an `Error` with `.status = 404` for an unknown project.
- `SYSTEM_COMMIT_MESSAGES: string[]` (see Global Constraints).
- [ ] **Step 1: Write the failing tests**
Setup:
- **Fake Appwrite:**
- `websiteProjects`:
- `p1` → `JUSN/D-ner-Nation`
- `p2` → `JUSN/leer`
- `p3` → `JUSN/nur-kunde`
- `p4` → `JUSN/doppelt`
All four have `customerId: ''` and `ticketId: ''`.
- `workorders`: `t1` with `woid: '100115'`.
- `customers`: `c1` with name `Döner Nation`.
- **Fake Gitea feeds:**
- `JUSN/D-ner-Nation`, newest first:
- a `webklar-system` push at `2026-09-27T14:50:00Z` with commit `s1`,
- a knso push at `14:48:00Z` with commit `k1` and the message `Update preview config from ticket system`, an old system commit that must not be backfilled,
- a JUSN push at `14:46:34Z` with commit `26bc22e…`,
- an empty-content push at `14:46:33Z`,
- `create_repo`.
- `JUSN/nur-kunde`: one JUSN push.
- `JUSN/doppelt`: one JUSN push.
- `JUSN/leer`: no pushes.
`now` is always `new Date('2026-09-27T16:00:00Z')`.
```js
test('assign speichert, verschiebt und trägt nach', async () => {
const r = await assignProject('p1', { customerId: 'c1', ticketId: 't1' }, { now })
assert.equal(r.ok, true)
assert.deepEqual(r.moved, { from: 'JUSN/D-ner-Nation', to: 'WEBklar/D-ner-Nation' })
assert.equal(r.backfilled, 1); assert.deepEqual(r.warnings, [])
const d = aw.get('websiteProjects', 'p1')
assert.deepEqual([d.customerId, d.ticketId], ['c1', 't1'])
const ws = aw.all('worksheets').filter((w) => w.workorderId === 't1')
assert.equal(ws.length, 1)
assert.deepEqual([ws[0].employeeName, ws[0].endDate, ws[0].endTime], ['JUSN', '27.09.2026', '1646'])
})
test('zweites Nachtragen erzeugt nichts Neues', async () => {
const r = await reconcileProject('p1', { now })
assert.deepEqual([r.moved, r.backfilled], [null, 0])
})
test('nur Kunde ohne Pushes: kein Ticket', async () => {
const before = aw.all('workorders').length
await assignProject('p2', { customerId: 'c1' }, { now })
assert.equal(aw.all('workorders').length, before)
})
test('nur Kunde mit Pushes: Webpage-Ticket entsteht', async () => {
const r = await assignProject('p3', { customerId: 'c1' }, { now })
const t = aw.all('workorders').find((w) => w.$id === aw.get('websiteProjects', 'p3').ticketId)
assert.equal(t.type, 'Webpage'); assert.equal(r.backfilled, 1)
})
test('Doppelklick: ein Umzug, keine doppelten Arbeitsblätter', async () => {
await Promise.all([assignProject('p4', { customerId: 'c1', ticketId: 't1' }, { now }), assignProject('p4', { customerId: 'c1', ticketId: 't1' }, { now })])
assert.equal(gitea.calls.filter((c) => c.path === '/api/v1/repos/JUSN/doppelt/transfer').length, 1)
assert.equal(aw.all('worksheets').filter((w) => w.details.includes('WEBklar/doppelt') || w.details.includes('JUSN/doppelt')).length, 1)
})
test('Gitea-Ausfall: Zuweisung bleibt', async () => {
gitea.setDown(true)
const r = await assignProject('p2', { customerId: 'c1', ticketId: 't1' }, { now })
gitea.setDown(false)
assert.equal(r.ok, true); assert.equal(aw.get('websiteProjects', 'p2').ticketId, 't1')
assert.match(r.warnings[0], /^Gitea ist gerade nicht erreichbar/)
})
test('unbekanntes Projekt', async () => {
await assert.rejects(assignProject('nope', { ticketId: 't1' }, { now }), (e) => e.status === 404)
})
```
- [ ] **Step 2: Run to verify it fails**
Run: `node --test test/projectAssignment.test.js`
Expected: FAIL, because the module is missing.
- [ ] **Step 3: Implement**
**`backfillPushes(project)`:**
1. Load the pushes with `listRecentPushes(project.repoFullName, { now })`. Drop the ones where `isSystemPusher` is true and the ones where the first line of every commit message is in `SYSTEM_COMMIT_MESSAGES`. If Gitea fails, return `giteaDown(err.message)`.
2. If no pushes remain, return `{ backfilled: 0, warnings: [] }`.
3. `ticketId = project.ticketId || await ensureProjectTicket(project, { repoFullName: project.repoFullName })`. Without a ticket, return 0.
4. For each push, oldest first: `createGitPushWorksheet({ ticketId, repoFullName: project.repoFullName, ...push })`. Count the results with `created`.
**`reconcileProject`** runs under the project lock:
1. `getDocument`, then `moveRepoToOrg`.
2. If something moved, load the document again.
3. Run `backfillPushes`.
4. Merge the warnings.
**`assignProject`:**
1. `getDocument` (404 → an `Error` with `status 404`).
2. `updateDocument({ customerId, ticketId, updatedAt })`.
3. `reconcileProject`.
4. Return `{ ok: true, … }`.
- [ ] **Step 4: Run all tests**
Run: `npm test`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add server/services/projectAssignment.js test/projectAssignment.test.js
git commit -m "Zuweisen mit Umzug und Nachtragen der Pushes"
```
---
### Task 7: Neue Repos finden, Abgleich ohne Subdomains, Selbstheilung nur für gehostete Projekte
**Files:**
- Modify: `server/services/giteaAdmin.js`
- Test: `test/giteaAdmin.test.js`
**Interfaces:**
- Consumes: `withLocks`, `repoLockKey` (Tasks 1, 5); `listAllAccessibleRepos` (unchanged)
- Produces:
- `isHostedProject(doc): boolean`. True if `['preview', 'website', 'template'].includes(doc.projectType)` or `['deployed', 'pending'].includes(doc.status)`.
- `importNewRepos(): Promise<{ created: number }>`, running under `withLocks(['import'])`.
- `syncReposToAppwrite()` keeps its signature, but no longer sets `subdomain` or `previewUrl`.
- `ensureRepoPreviewConfig(repoFullName, branch)` returns `null` when `!isHostedProject(doc)`.
- [ ] **Step 1: Write the failing tests**
Setup:
- **Fake Gitea repos:** `knso/alt`, which has a document, `JUSN/neu` with description `Döner Nation`, `JUSN/archiv` with `archived: true`, `knso/privat` with `private: true`, and `knso/synced-sub`, `knso/lead`.
- **Fake Appwrite:**
- `knso/alt` with subdomain `alt`,
- `knso/synced-sub` with `status: 'synced'`, `subdomain: 'synced-sub'`,
- `knso/lead` with `status: 'deployed'`, `subdomain: 'lead'`.
```js
test('importNewRepos legt nur fehlende an', async () => {
assert.deepEqual(await importNewRepos(), { created: 2 })
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'))
assert.ok(!aw.all('websiteProjects').some((d) => d.repoFullName === 'JUSN/archiv'))
assert.equal(aw.calls.filter((c) => c.method === 'PATCH').length, 0)
})
test('importNewRepos läuft nie doppelt', async () => {
gitea.addRepo({ full_name: 'ANDJ/zweimal' })
await Promise.all([importNewRepos(), importNewRepos()])
assert.equal(aw.all('websiteProjects').filter((d) => d.repoFullName === 'ANDJ/zweimal').length, 1)
})
test('Abgleich vergibt keine Subdomain mehr', async () => {
gitea.addRepo({ full_name: 'ANDJ/sync-neu' })
await syncReposToAppwrite()
const d = aw.all('websiteProjects').find((x) => x.repoFullName === 'ANDJ/sync-neu')
assert.equal(d.subdomain || '', ''); assert.equal(d.previewUrl || '', '')
assert.equal(aw.all('websiteProjects').find((x) => x.repoFullName === 'knso/alt').subdomain, 'alt')
})
test('isHostedProject', () => {
for (const [doc, want] of [[{ projectType: 'preview' }, true], [{ projectType: 'website' }, true], [{ projectType: 'template' }, true],
[{ status: 'deployed' }, true], [{ status: 'pending' }, true], [{ status: 'synced', subdomain: 'x' }, false], [{}, false]]) {
assert.equal(isHostedProject(doc), want, JSON.stringify(doc))
}
})
test('Selbstheilung nur für gehostete Projekte', async () => {
assert.equal(await ensureRepoPreviewConfig('knso/synced-sub', 'main'), null)
assert.equal(gitea.calls.filter((c) => c.path.includes('synced-sub/contents')).length, 0)
const cfg = await ensureRepoPreviewConfig('knso/lead', 'main')
assert.equal(cfg.subdomain, 'lead')
assert.ok(gitea.calls.some((c) => c.method === 'POST' && c.path.includes('knso/lead/contents/.webklar-preview.json')))
})
```
- [ ] **Step 2: Run to verify it fails**
Run: `node --test test/giteaAdmin.test.js`
Expected: FAIL
- [ ] **Step 3: Implement**
- **`importNewRepos`:**
1. Load all repos with `listAllAccessibleRepos()` and all documents with `listDocuments(websiteProjects, [Query.limit(5000)])`. Compare case-insensitively on `repoFullName`.
2. For every non-archived repo without a document, take `withLocks([repoLockKey(full_name)])`, check again, then `upsertWebsiteProjectByRepo(full_name, { projectName: description || name, giteaRepoUrl: html_url, giteaRepoName: name, repoFullName: full_name, archived: false, status: 'synced', giteaSyncedAt: now })`.
- **`syncReposToAppwrite`:** Delete the call to `resolveSubdomainForSync` and the `subdomain`/`previewUrl` lines, and delete the now unused function.
- **`ensureRepoPreviewConfig`:** Check with `isHostedProject(doc)` instead of `hasWebsite`. Keep `deriveProjectType` for the content of the config.
- [ ] **Step 4: Run all tests**
Run: `npm test`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add server/services/giteaAdmin.js test/giteaAdmin.test.js
git commit -m "Neue Repos importieren, Abgleich ohne Subdomains, Selbstheilung nur für gehostete Projekte"
```
---
### Task 8: Schnittstellen und Webhook
**Files:**
- Modify: `server/routes/admin/gitea.js`, `server/routes/admin/websiteProjects.js`, `server/routes/webhook/gitea.js`
- Test: `test/giteaRoutes.test.js`
**Interfaces:**
- Consumes: `importNewRepos` (Task 7); `assignProject`, `reconcileProject` (Task 6); `withLocks` (Task 1); `repoLockKey`, `currentRepoFullName` (Task 5)
- Produces:
- **`POST /api/admin/gitea/import-repos`** returns `200 { ok: true, created }`.
- **`POST /api/admin/website-projects/:id/assign`** with the body `{ customerId, ticketId }`:
- `200 { ok: true, project, moved, backfilled, warnings }`,
- `400 { error: 'customerId oder ticketId erforderlich' }` if both are empty,
- `404 { error: 'Projekt nicht gefunden' }`.
- **`POST /api/admin/website-projects/:id/pushes`** returns `200 { ok: true, moved, backfilled, warnings }`.
- **Webhook:** The processing runs inside `withLocks([repoLockKey(payloadName)], …, { waitMs: 10000 })`. Within it, `repoFullName = await currentRepoFullName(payloadName)`, and all later steps use this name.
- [ ] **Step 1: Write the failing tests**
- **Test app:** `express()` with `express.json({ verify: (req, _res, buf) => { req.rawBody = buf } })`, mounted under the same paths as `server/index.js`.
- **Environment:** `GITEA_WEBHOOK_TOKEN=s3cret` and `PREVIEW_DEPLOY_ROOT=` (empty).
- **Signature:** `X-Gitea-Signature = createHmac('sha256', 's3cret').update(body).digest('hex')`.
- **Data:** the same as in Task 6 for `p1`/`t1`, plus `p5` → `JUSN/langsam` (repo in the fake Gitea, no pushes).
```js
test('import-repos braucht Mitarbeiter-JWT', async () => {
assert.equal((await post('/api/admin/gitea/import-repos')).status, 401)
const r = await post('/api/admin/gitea/import-repos', {}, 'jwt-admin')
assert.equal(r.status, 200); assert.equal(typeof r.body.created, 'number')
})
test('assign über die Schnittstelle', async () => {
const r = await post('/api/admin/website-projects/p1/assign', { customerId: 'c1', ticketId: 't1' }, 'jwt-admin')
assert.equal(r.status, 200)
assert.deepEqual([r.body.moved.to, r.body.backfilled, r.body.warnings], ['WEBklar/D-ner-Nation', 1, []])
})
test('assign ohne Kunde und Ticket', async () => {
const r = await post('/api/admin/website-projects/p1/assign', {}, 'jwt-admin')
assert.deepEqual([r.status, r.body.error], [400, 'customerId oder ticketId erforderlich'])
})
test('pushes über die Schnittstelle', async () => {
const r = await post('/api/admin/website-projects/p1/pushes', {}, 'jwt-admin')
assert.deepEqual([r.status, r.body.moved, r.body.backfilled], [200, null, 0])
})
test('Webhook nach Umzug mit altem Namen', async () => {
const docs = aw.all('websiteProjects').length
const r = await webhook(pushPayload({ full_name: 'JUSN/D-ner-Nation', sha: 'f00dbeef', pusher: 'JUSN' }))
assert.equal(r.status, 200)
assert.equal(aw.all('websiteProjects').length, docs)
assert.ok(aw.all('worksheets').some((w) => w.workorderId === 't1' && w.details.includes('Commit: f00dbee')))
})
test('Webhook des System-Kontos', async () => {
const before = aw.all('worksheets').length
await webhook(pushPayload({ full_name: 'WEBklar/D-ner-Nation', sha: 'cafe0001', pusher: 'webklar-system' }))
assert.equal(aw.all('worksheets').length, before)
})
test('Webhook wartet auf laufenden Umzug', async () => {
gitea.setTransferDelay(300)
const move = post('/api/admin/website-projects/p5/assign', { customerId: 'c1', ticketId: 't1' }, 'jwt-admin') // p5: JUSN/langsam
await sleep(50)
await webhook(pushPayload({ full_name: 'WEBklar/langsam', sha: 'abcd1234', pusher: 'JUSN' }))
await move
assert.equal(aw.all('websiteProjects').filter((d) => d.repoFullName === 'WEBklar/langsam').length, 1)
})
```
- [ ] **Step 2: Run to verify it fails**
Run: `node --test test/giteaRoutes.test.js`
Expected: FAIL. The routes are missing, and the webhook creates a second document for the old name.
- [ ] **Step 3: Implement**
- Add the routes the same way as the existing ones in the same files. Errors respond with `err.status || 500` and `{ error: err.message }`.
- In the webhook handler, put the existing body into `withLocks` and use `currentRepoFullName` before `fetchPreviewConfig`. Only the name source and the lock change.
- [ ] **Step 4: Run all tests**
Run: `npm test`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add server/routes/admin/gitea.js server/routes/admin/websiteProjects.js server/routes/webhook/gitea.js test/giteaRoutes.test.js
git commit -m "Schnittstellen für Import, Zuweisen, Nachtragen; Webhook wartet auf Umzug"
```
---
### Task 9: Skripte für Einrichtung und Umzug
**Files:**
- Create: `scripts/gitea-organisation-setup.mjs`, `scripts/gitea-organisation-migrate.mjs`
- Test: `test/giteaScripts.test.js`
**Interfaces:**
- Consumes: `reconcileProject` (Task 6); `isHostedProject`, `ensureRepoPreviewConfig`, `getRepoInfo` (`giteaAdmin.js`); `fetchPreviewConfig` (`previewDeploy.js`)
- Produces:
- **Setup script, exports:**
- `readEnvFile(path): Record<string, string>`. It reads `KEY=VALUE` lines, ignores `#` comments, strips surrounding `"` or `'`, and never prints anything.
- `setupOrganisation({ baseUrl, token, org = 'WEBklar', members = ['knso', 'jusn', 'andj'], hookUrl = 'https://project.webklar.com/webhook/gitea', secret }): Promise<{ orgCreated, membersAdded: string[], hookCreated }>`. It is idempotent.
- **Setup script, command line:** `node scripts/gitea-organisation-setup.mjs --env /home/webklar/apps/.env`. It uses `GITEA_SYSTEM_TOKEN` and `GITEA_WEBHOOK_TOKEN` from the file, `baseUrl` `https://git.webklar.com`, and prints the result as JSON.
- **Migration script, exports:**
- `CUSTOMER_REPOS = ['knso/emmas-schatzkiste', 'knso/Anelia-Wolf', 'ANDJ/Unger-Bus-Unternehmen', 'JUSN/D-ner-Nation', 'knso/videogen', 'knso/kfz']`
- `migrateRepos({ repoFullNames = CUSTOMER_REPOS, backupDir, now = new Date() }): Promise<Array<{ from, to, backfilled, warnings, previewConfigWritten }>>`
- **Migration script, command line:** `node scripts/gitea-organisation-migrate.mjs`, run inside the container. `backupDir` is `/app/upload-data/gitea-org`.
- **Order inside the migration:**
1. First write `hooks-<ISO>.json` with the hooks of all repos (`GET /repos/{r}/hooks`: id, url, events).
2. Then, for each repo: find the document by `repoFullName`; if it is missing, return `warnings: ['kein Projekt-Eintrag']` and continue.
3. Call `reconcileProject(doc.$id, { now })`.
4. If `isHostedProject` is true and `fetchPreviewConfig(to, defaultBranch)` returns null, call `ensureRepoPreviewConfig(to, defaultBranch)` and set `previewConfigWritten: true`.
- [ ] **Step 1: Write the failing tests**
```js
test('Einrichtung legt Organisation, Mitglieder und Webhook an', async () => {
const r = await setupOrganisation({ baseUrl: gitea.baseUrl, token: 't', secret: 's3cret' })
assert.deepEqual(r, { orgCreated: true, membersAdded: ['knso', 'jusn', 'andj'], hookCreated: true })
assert.deepEqual(gitea.calls.find((c) => c.method === 'POST' && c.path === '/api/v1/orgs').body, { username: 'WEBklar', full_name: 'WEBklar', visibility: 'public' })
assert.deepEqual(gitea.calls.find((c) => c.path === '/api/v1/orgs/WEBklar/hooks' && c.method === 'POST').body,
{ type: 'gitea', active: true, events: ['push'], branch_filter: '*', config: { url: 'https://project.webklar.com/webhook/gitea', content_type: 'json', secret: 's3cret' } })
})
test('Einrichtung ist wiederholbar', async () => {
assert.deepEqual(await setupOrganisation({ baseUrl: gitea.baseUrl, token: 't', secret: 's3cret' }), { orgCreated: false, membersAdded: [], hookCreated: false })
})
test('readEnvFile', async () => {
await fs.writeFile(envPath, '# x\nGITEA_SYSTEM_TOKEN="abc"\nGITEA_WEBHOOK_TOKEN=def\n')
assert.deepEqual(readEnvFile(envPath), { GITEA_SYSTEM_TOKEN: 'abc', GITEA_WEBHOOK_TOKEN: 'def' })
})
test('Umzug sichert Webhooks und schreibt fehlende Preview-Einstellung', async () => {
const r = await migrateRepos({ repoFullNames: ['knso/emmas-schatzkiste', 'JUSN/D-ner-Nation'], backupDir, now })
assert.deepEqual(r.map((x) => [x.to, x.previewConfigWritten]), [['WEBklar/emmas-schatzkiste', false], ['WEBklar/D-ner-Nation', true]])
const [file] = await fs.readdir(backupDir)
assert.equal(JSON.parse(await fs.readFile(path.join(backupDir, file), 'utf8'))['knso/emmas-schatzkiste'][0].id, 110)
})
```
Test data:
- `knso/emmas-schatzkiste`: hook 110 to the portal, `.webklar-preview.json` present, document with `projectType: 'website'`.
- `JUSN/D-ner-Nation`: no config, document with `projectType: 'preview'`, `subdomain: 'doener-nation'`.
- [ ] **Step 2: Run to verify it fails**
Run: `node --test test/giteaScripts.test.js`
Expected: FAIL
- [ ] **Step 3: Implement both scripts**
Run the command-line part only if `import.meta.url === pathToFileURL(process.argv[1]).href`. The setup script does:
1. `GET /orgs/{org}`. On 404, `POST /orgs`.
2. `GET /orgs/{org}/teams` and pick Owners. Add every member that is not yet in it with `PUT /teams/{id}/members/{user}`. Check first with `GET /teams/{id}/members`.
3. `GET /orgs/{org}/hooks`. If no hook has `config.url === hookUrl`, `POST` one.
- [ ] **Step 4: Run all tests**
Run: `npm test`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add scripts/gitea-organisation-setup.mjs scripts/gitea-organisation-migrate.mjs test/giteaScripts.test.js
git commit -m "Skripte: Organisation einrichten, Kundenprojekte umziehen"
```
---
### Task 10: Gesamtprüfung unter Node 20
**Files:** keine Änderungen
- [ ] **Step 1: Run the tests on the host (Node 24)**
Run: `npm test`
Expected: all tests pass, with no `todo` and no `skip`.
- [ ] **Step 2: Run the tests on Node 20, the production runtime**
Run:
```bash
docker run --rm -v /home/webklar/apps/kundenbereich.webklar.com:/home/webklar/apps/kundenbereich.webklar.com:ro -w /home/webklar/apps/kundenbereich.webklar.com/dev-gitea-org node:20-alpine npm test
```
Expected: the same number of passing tests as in Step 1.
- [ ] **Step 3: Start check without production**
Run `node server/index.js` from the worktree with `env -i PATH=$PATH PORT=3197 APPWRITE_ENDPOINT=http://127.0.0.1:9/v1 APPWRITE_API_KEY=x GITEA_API_TOKEN=x`. Expected: `läuft auf Port 3197`, and no import or syntax error. `config.js` falls back to PROD for `APPWRITE_ENDPOINT`, which is why the value is set explicitly. Stop the process by its PID.
- [ ] **Step 4: Review**
Run a fresh code review of the whole branch against the spec (`superpowers:requesting-code-review`). Fix any findings in their own commits.
---
## Livegang (Phasen 1, 2, 4) – jeder Schritt nur mit OK des Nutzers
**L0 Sicherungen**
```bash
mkdir -p /root/reports/gitea-org-2026-09-27 && chmod 700 /root/reports/gitea-org-2026-09-27
cp /home/webklar/apps/docker-compose.yml /home/webklar/apps/docker-compose.yml.bak-gitea-org
cp /home/webklar/apps/.env /home/webklar/apps/.env.bak-gitea-org
docker exec webklar-gitea-db sh -c 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -t webhook -t repository -t collaboration -t repo_redirect' > /root/reports/gitea-org-2026-09-27/gitea-vorher.sql
chmod 600 /root/reports/gitea-org-2026-09-27/gitea-vorher.sql
```
Also take the preview statuses before the migration:
```bash
for s in emmas-schatzkiste anelia-wolf unger-reisen doener-nation videogen kfz; do printf '%s ' $s; curl -s -o /dev/null -w '%{http_code}\n' https://$s.project.webklar.com/; done > /root/reports/gitea-org-2026-09-27/previews-vorher.txt
```
**L1 System-Konto und Schlüssel (Phase 1)**
```bash
docker exec -u git webklar-gitea gitea admin user create --username webklar-system --email webklar-system@noreply.webklar.com --admin --random-password --must-change-password=false > /dev/null
printf '\nGITEA_SYSTEM_TOKEN=%s\n' "$(docker exec -u git webklar-gitea gitea admin user generate-access-token --username webklar-system --token-name portal --scopes write:repository,write:organization,read:user --raw)" >> /home/webklar/apps/.env
grep -c '^GITEA_SYSTEM_TOKEN=.\{30,\}$' /home/webklar/apps/.env
```
Expected: `1`. The token is never printed. Gitea sends no mails (`[mailer] ENABLED = false`), so there are no notification settings to change.
**L2 Organisation (Phase 1)**
Run `node scripts/gitea-organisation-setup.mjs --env /home/webklar/apps/.env` on the host, in the worktree. Expected: `{"orgCreated":true,"membersAdded":["knso","jusn","andj"],"hookCreated":true}`.
Check it with a read-only query against the Gitea DB:
- the organisation `webklar` exists,
- the Owners team contains `webklar-system`, `knso`, `jusn` and `andj`,
- there is one webhook with `owner_id` = the organisation and a URL starting with `https://project.webklar.com/webhook/gitea`.
**L3 Portal-Einstellungen (Phase 2)**
In `docker-compose.yml`, service `kundenbereich-server`:
- `GITEA_API_TOKEN=${GITEA_SYSTEM_TOKEN}`
- `GITEA_DEFAULT_OWNER=${GITEA_DEFAULT_OWNER:-WEBklar}`
- new: `GITEA_ORG=WEBklar` and `GITEA_SYSTEM_USER=webklar-system`
Then run `cd /home/webklar/apps && docker compose up -d --no-deps kundenbereich-server`.
Check:
1. The log shows `[startup] Preview-TLS-Sync: {"synced":true,…,"publicSource":"appwrite"}`.
2. This command prints `webklar-system true`:
```bash
docker exec webklar-kundenbereich-server node -e "fetch(process.env.GITEA_BASE_URL+'/api/v1/user',{headers:{Authorization:'token '+process.env.GITEA_API_TOKEN}}).then(r=>r.json()).then(u=>console.log(u.login,u.is_admin))"
```
**L4 Branch prüfen**
- `git fetch origin && git log --oneline origin/main -3`.
- Another session may have pushed to `main` in the meantime. In that case rebase (`git rebase origin/main`), run `npm test` again, and have a range-diff checked.
**L5 Code live (Phase 2)**
Push with the user's OK: `git -C /home/webklar/apps/kundenbereich.webklar.com/dev-gitea-org push origin feature/gitea-organisation:main`.
Check:
1. `build/` is at the new commit, and the container restarted without errors.
2. `curl -s -o /dev/null -w '%{http_code}' -X POST https://project.webklar.com/api/admin/gitea/import-repos` returns `401`.
3. The Preview sync reports 104 hosts or more, with `publicSource` `appwrite`.
**L6 Umzug der Kundenprojekte (Phase 4)**
Run `docker exec -w /app webklar-kundenbereich-server node scripts/gitea-organisation-migrate.mjs`. Expected: 6 entries. Each has `to` beginning with `WEBklar/` and no warning. For Döner Nation, `backfilled` is at least 1 and `previewConfigWritten` is `true`. For Anelia Wolf, `backfilled` is 1: the push `first publish` by knso from 06.09., which happened before its webhook existed. The system commit from the same day is skipped. For the other four, `backfilled` is 0; they had no pushes in the 30 days before 27.09.
Check:
- The statuses in `previews-vorher.txt` are unchanged when measured again.
- The 6 project documents carry the new names.
- Ticket 100115 has an arbeitsblatt from `JUSN` with end 16:46.
- The per-repo hooks 110, 100, 13 and 59 are gone.
Then tell the employees the per-repo commands `git remote set-url origin https://git.webklar.com/WEBklar/<repo>.git`, and tell Justin to run `git pull` once before his next push.
**L7 Live-Durchlauf „eigenes Konto“** (spec 11), only with the user's explicit OK. It runs as a script in the portal container, and nothing is printed except results.
1. Create `knso/umzug-probe` with knso's old key (the value of `GITEA_WEBHOOK_TOKEN` in the container): `POST /user/repos` with `{ name: 'umzug-probe' }`, then create `index.html` through the contents API. That is a push by knso.
2. Call `importNewRepos()`. There is then an entry for `knso/umzug-probe`.
3. Call `assignProject(<entry>, { ticketId: <$id of test ticket #99999> })`. Expected: `moved.to === 'WEBklar/umzug-probe'`, `backfilled === 1`, and an arbeitsblatt with the German push time.
4. Make a second file change with knso's key in `WEBklar/umzug-probe`. Within a minute, the organisation webhook produces a second arbeitsblatt on #99999. `GET /repos/WEBklar/umzug-probe/hooks` returns `[]`.
5. Clean up: delete the repo (`DELETE /repos/WEBklar/umzug-probe`), the `websiteProjects` entry, and both arbeitsblätter, then check that all three are gone.
**L8 Notizen**
Update the memory notes (`webklar-preview-projekte`, `webklar-kundenportal`).
**Rückweg**
- **L3:** restore `docker-compose.yml.bak-gitea-org` and run `docker compose up -d --no-deps kundenbereich-server`.
- **L5:** `git revert` the commits on a branch, then push after the OK.
- **L6:** transfer every repo back to its old owner (`POST /repos/WEBklar/<r>/transfer` with `new_owner`), then recreate the hooks from `hooks-<ISO>.json` with the secret `GITEA_WEBHOOK_TOKEN`. Use a script in the container, so the secret is never printed.