Files
tessera-ctl/.planning/quick/261002-k67-modul-nextcloud-status-mit-ampel-kacheln/261002-k67-PLAN.md
T
schalli 6829c44464 docs(quick-261002-k67): Modul Nextcloud-Status
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 15:04:01 +02:00

346 lines
61 KiB
Markdown
Raw 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.
---
phase: quick-261002-k67
plan: 01
type: execute
wave: 1
depends_on: []
quick_id: 261002-k67
description: "Neues Modul Nextcloud-Status: Clouds als Ampel-Kacheln mit Versionsbewertung, stündlicher Prüfung und Dashboard-Kachel"
date: 2026-10-02
files_modified:
# Task 1 — tracer: DB -> status.php check -> eol reference -> rating -> list API -> module page tiles
- apps/api/prisma/schema.prisma
- apps/api/prisma/migrations/20261002150000_nextcloud_status/migration.sql
- apps/api/src/nextcloud-status/nextcloud-rating.ts
- apps/api/src/nextcloud-status/nextcloud-rating.spec.ts
- apps/api/src/nextcloud-status/nextcloud-status-fetch.ts
- apps/api/src/nextcloud-status/nextcloud-status-fetch.spec.ts
- apps/api/src/nextcloud-status/nextcloud-release.service.ts
- apps/api/src/nextcloud-status/nextcloud-release.service.spec.ts
- apps/api/src/nextcloud-status/nextcloud-status.service.ts
- apps/api/src/nextcloud-status/nextcloud-status.service.spec.ts
- apps/api/src/nextcloud-status/nextcloud-status.controller.ts
- apps/api/src/nextcloud-status/nextcloud-status.controller.spec.ts
- apps/api/src/nextcloud-status/dto/nextcloud-instance.dto.ts
- apps/api/src/nextcloud-status/nextcloud-status.seed.ts
- apps/api/src/nextcloud-status/nextcloud-status.module.ts
- apps/api/src/app.module.ts
- docs/mandantentrennung-zugriffsklassifikation.md
- apps/web/src/lib/nextcloud-status-api.ts
- apps/web/src/components/nextcloud-status/rating-display.ts
- apps/web/src/app/(portal)/modules/nextcloud-status/layout.tsx
- apps/web/src/app/(portal)/modules/nextcloud-status/page.tsx
- apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudTile.tsx
- apps/web/src/app/(portal)/modules/nextcloud-status/nextcloud-status-page.test.tsx
- apps/web/src/app/(portal)/modules/module-layouts.test.tsx
- apps/web/src/lib/module-loader.ts
- apps/web/src/lib/module-identity.ts
- apps/web/src/lib/stores/nav-store.ts
- apps/web/src/components/modules/module-tile.tsx
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- apps/web/src/messages/umlaut-dictionary.ts
# Task 2 — manager write paths, logo, check-all, hourly scheduler, sorting
- apps/api/src/nextcloud-status/nextcloud-logo-rules.ts
- apps/api/src/nextcloud-status/nextcloud-logo-rules.spec.ts
- apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.ts
- apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.spec.ts
- apps/api/src/module-registry/module-manage-handlers.spec.ts
- apps/api/src/prisma/rls-access-inventory.spec.ts
- apps/web/src/components/nextcloud-status/sort-clouds.ts
- apps/web/src/components/nextcloud-status/sort-clouds.test.ts
- apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.tsx
- apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.test.tsx
# Task 3 — dashboard widget, docs, changelog, full suites, rebuild
- packages/shared/src/index.ts
- apps/api/src/dashboard/widget-module-map.ts
- apps/api/src/dashboard/widget-module-map.spec.ts
- apps/web/src/components/dashboard/widget-registry.tsx
- apps/web/src/components/dashboard/widget-registry.test.tsx
- apps/web/src/components/dashboard/widget-catalog-modal.test.tsx
- apps/web/src/components/dashboard/widgets/widget-icon.tsx
- apps/web/src/components/dashboard/widgets/widget-wrapper.tsx
- apps/web/src/components/dashboard/widgets/nextcloud-status-widget.tsx
- apps/web/src/components/dashboard/widgets/nextcloud-status-widget.test.tsx
- apps/web/src/app/(portal)/page.tsx
- apps/web/src/app/(portal)/page.test.tsx
- CHANGELOG.md
- docs/anleitung-anwender.md
- docs/anleitung-administration.md
autonomous: true
requirements: [QUICK-261002-k67]
estimate:
tokens: 170000
raw_tokens: 170000
tasks: 3
confidence: low
must_haves:
truths:
- "A user with grant level Benutzen (USE) on nextcloud-status sees one tile per cloud: logo (or initials), Kundenname, URL link opening in a new tab, installed version, traffic-light color with a short plain-language reason, and the time of the last check; the page also names the newest overall Nextcloud version"
- "The traffic light follows the locked rules: GREEN = latest patch of its cycle and EOL more than 90 days away; YELLOW = patch update available in its cycle or EOL within 90 days; RED = EOL passed (or major older than every listed cycle), unreachable/invalid answer, maintenance mode, or needsDbUpgrade; without reference data the tile is grey with 'Bewertung nicht möglich' (red status conditions still red)"
- "Only administrators and users with Verwalten (MANAGE) can add/edit/delete clouds, upload/remove logos, and trigger 'Jetzt prüfen' or a per-tile check — API returns 403 for USE-level users and the controls are hidden for them"
- "Every cloud is checked automatically once per hour (also on a fresh database after the first start), each check fetches only <url>/status.php with a 10 s timeout, max 3 redirects and a size cap, and only parsed fields reach the browser"
- "Version reference data from endoflife.date is cached for 12 h; an outage keeps the last good data and never breaks the page"
- "The user can sort tiles by Kundenname, Status (red first), Version, or Support-Ende, and the choice survives a reload for the same user"
- "Users with module access can place a 'Nextcloud-Status' dashboard tile showing green/yellow/red counters and the red/yellow clouds with reason; clicking opens the module; users without access never see the tile in the catalog or on the dashboard"
artifacts:
- path: "apps/api/prisma/migrations/20261002150000_nextcloud_status/migration.sql"
provides: "NextcloudInstance table with tenant_isolation_policy and system_read_policy"
contains: "NextcloudInstance"
- path: "apps/api/src/nextcloud-status/nextcloud-rating.ts"
provides: "pure rating function with injected date"
exports: ["rateNextcloud"]
- path: "apps/api/src/nextcloud-status/nextcloud-status-fetch.ts"
provides: "normalizeCloudUrl, parseNextcloudStatus, fetchNextcloudStatus (injectable fetch)"
exports: ["normalizeCloudUrl", "parseNextcloudStatus", "fetchNextcloudStatus"]
- path: "apps/api/src/nextcloud-status/nextcloud-release.service.ts"
provides: "endoflife.date cache (12 h, keep last good, backoff) + parseEndOfLife"
- path: "apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.ts"
provides: "hourly check job registered in onApplicationBootstrap"
- path: "apps/web/src/app/(portal)/modules/nextcloud-status/page.tsx"
provides: "tile grid, sorting, manager controls"
- path: "apps/web/src/components/dashboard/widgets/nextcloud-status-widget.tsx"
provides: "dashboard overview tile"
key_links:
- from: "apps/api/src/nextcloud-status/nextcloud-status.service.ts"
to: "rateNextcloud + NextcloudReleaseService.getReference"
via: "listForTenant maps every row to a rating at read time"
pattern: "rateNextcloud\\("
- from: "apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.ts"
to: "NextcloudStatusService.loadAllInstancesForScheduler (forSystem) -> checkInstance (forTenant)"
via: "onApplicationBootstrap registers the cron job nextcloud-status-poll"
pattern: "onApplicationBootstrap"
- from: "packages/shared/src/index.ts WIDGET_MODULE_SLUGS"
to: "DashboardService fail-closed filter + web catalog visibleWidgetTypes"
via: "'nextcloud-status': 'nextcloud-status'"
pattern: "'nextcloud-status': 'nextcloud-status'"
- from: "apps/web/src/app/(portal)/modules/nextcloud-status/page.tsx"
to: "useCanManageModule('nextcloud-status')"
via: "manager-only controls"
pattern: "useCanManageModule\\('nextcloud-status'\\)"
---
<objective>
New Tessera module "Nextcloud-Status" (slug `nextcloud-status`, category `infrastructure`, next to Proxmox). Managers register customer Nextcloud instances (URL + Kundenname + optional logo); Tessera checks each instance's public `status.php` every hour and on demand, compares the installed version with the endoflife.date reference data and shows a traffic-light tile per cloud plus a dashboard overview tile.
Locked decisions from the request (cited below as L-xx):
- L-01 Slug `nextcloud-status`, category `infrastructure`; follow the Proxmox module end to end (seed, NestJS module, ModuleGuard, Prisma model with RLS + hand-written migration, web route with ModuleAccessGate, sidebar/marketplace/icon registrations, module-layouts test, dashboard widget registration).
- L-02 Managers add a cloud with URL and Kundenname; optional logo either uploaded (PostgreSQL bytea, magic-byte type check, 1 MiB limit, served via an authenticated route) or an https URL the browser loads directly — the API never fetches the logo URL.
- L-03 Status: server-side GET `<url>/status.php`, ~10 s timeout, no credentials, http and https, at most a few redirects, capped response size; last result stored on the record (versionstring, maintenance, reachable, error text, checkedAt). URL policy like Proxmox (manager-entered, internal hosts allowed) with the SSRF consideration documented in a code comment; only parsed JSON fields, never raw bodies, reach the client.
- L-04 Reference data from https://endoflife.date/api/nextcloud.json, fetched server-side, cached ~12 h, outage-tolerant (keep last good); never fetched → tiles show the version without rating (grey "Bewertung nicht möglich").
- L-05 Traffic light (locked): GREEN = latest patch of its major cycle AND cycle still supported; YELLOW = update available within its cycle OR cycle EOL within the next 90 days; RED = cycle EOL passed (or major not in the list and older than all listed), unreachable, maintenance on, or needsDbUpgrade. Also show the newest overall Nextcloud version. Rating is a pure function with thorough Vitest tests and an injected date.
- L-06 Tile: logo, Kundenname, URL (new tab), installed version, status color + short German reason ("Aktuell", "Update auf 34.0.4 verfügbar", "Support endet am 30.06.2027", "Support abgelaufen seit …", "Nicht erreichbar", "Wartungsmodus"), last check time.
- L-07 Sorting selectable: Kundenname (A–Z), Status (rot zuerst), Version, Support-Ende; remembered per user in localStorage (try/catch).
- L-08 Polling hourly via scheduler (Proxmox/Tender pattern incl. the onApplicationBootstrap lesson), plus "Jetzt prüfen" (whole list) and per-tile refresh; background checks per tenant in system context like the other background jobs.
- L-09 Rights (locked): USE sees tiles; MANAGE (`@ModuleManage`) or admin adds/edits/deletes clouds, uploads logos, triggers checks. Web uses `useCanManageModule`.
- L-10 Dashboard widget (locked): counters (grün/gelb/rot) and the red/yellow clouds (name + reason); click opens the module; registered and sized like the Proxmox widget.
- L-11 UI texts German (formal "Sie") and English; UI texts never name the tenant concept; dark/light via existing tokens.
- L-12 Tests: api Vitest for rating, status.php parser (valid, maintenance, garbage, timeout), eol cache, service CRUD, controller guard metadata (static routes before `:id`); web tests for page (tiles, sorting, manager-only controls) and widget; full api + web suites, tsc, biome on touched files.
- L-13 CHANGELOG (Unveröffentlicht, user-facing German) + user/admin docs in `docs/`.
- L-14 Local migration via container IP, rebuild `docker compose up -d --build api web`; do NOT push; browser check is the orchestrator's job.
Claude's discretion (decided here, apply as written):
- D-A Logo bytes live in bytea columns on the instance row as L-02 says (note: dashboard images moved to the file area in 260922-hk4; for a handful of logos ≤ 1 MiB the DB is fine and needs no file cleanup). Every list/scheduler query uses an explicit `select` without the bytes. Accepted types PNG/JPEG/GIF/WebP via the existing `detectImageMime` — no SVG (script risk). Upload and logo URL are mutually exclusive: uploading clears `logoUrl`; saving a non-empty `logoUrl` clears the upload.
- D-B The rating is computed in the API at read time (`GET instances`), so page and widget show the same result; the API returns reason codes plus parameters, the web translates them (German + English).
- D-C One global hourly cron job `nextcloud-status-poll` (`0 * * * *`), registered unconditionally in `onApplicationBootstrap` without reading the database at registration time — a fresh database cannot end up without the job (Tender lesson). Each tick reads `(id, tenantId)` of all instances in system context (the single `forSystem` call), then checks each instance tenant-bound with concurrency 4 and an overlap guard.
- D-D Reference cache in memory (no table): TTL 12 h; stale data is returned immediately and refreshed in the background; only an empty cache is awaited; after a failure no new attempt for 15 min; concurrent refreshes share one request; bootstrap warms it up without blocking.
- D-E A major newer than every listed cycle (fresh release not yet on endoflife.date) rates GREEN "Aktuell"; a major inside the listed range but missing from it rates grey.
- D-F An answer that is not a valid Nextcloud status JSON (or `installed` not true) is RED with its own reason "Keine gültige Nextcloud-Antwort" (variant of "unreachable").
- D-G Status sort order red, yellow, grey, green (then name); version sort oldest first, unknown last; Support-Ende earliest first, unknown last; ties by name.
- D-H TLS certificates of the clouds are verified (no opt-out); a certificate error shows as "Nicht erreichbar" with the error code as detail.
Output: migration + model, API module (pure functions, release cache, service, controller, scheduler, seed), module page with tiles/sorting/manager form, dashboard widget, tests, docs, changelog, rebuilt local stack. Three atomic commits on main, NOT pushed.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@./CLAUDE.md
Discovered facts the executor can rely on (verified during planning):
- Proxmox touchpoints (grep `proxmox` across apps/ and packages/) are the template: `apps/api/src/proxmox/{proxmox.module.ts, proxmox.seed.ts, proxmox.controller.ts, proxmox.service.ts, proxmox-scheduler.service.ts}`, `apps/api/src/app.module.ts`, migration `20260923140000_proxmox_server`, web `apps/web/src/app/(portal)/modules/proxmox/{layout.tsx,page.tsx}`, `apps/web/src/lib/{proxmox-api.ts,module-loader.ts,module-identity.ts,stores/nav-store.ts}`, `apps/web/src/components/modules/module-tile.tsx` (ICONS map keyed by `ModuleIconId`), widget files under `apps/web/src/components/dashboard/`.
- `PrismaService` is injected without importing a Prisma module (global); `forTenant`/`forSystem` come from `apps/api/src/prisma/prisma-tenant.extension.ts`. `ModuleRegistryModule` must be imported for `ModuleRegistryService` (seed) and `ModuleGuard`. `ScheduleModule` is already global in app.module.ts.
- `@UseModule(slug)` (class) and `@ModuleManage(slug)` (handler) live in `apps/api/src/module-registry/module.guard.ts` (quick 261002-icv). Never put a role decorator on a manage handler — the global RolesGuard would block managers. `GET /modules/active` already carries `canManage`; `apps/web/src/lib/use-module-capability.ts` exports `useCanManageModule`.
- Cron: `ProxmoxSchedulerService` resolves `CronJob` via `require('cron').CronJob` (pnpm strict isolation) and registers with `SchedulerRegistry.addCronJob` — reuse that workaround verbatim.
- HTTP: `apps/api/src/favorites/icon-discovery.service.ts` uses `fetch as undiciFetch` from `undici` (dependency 7.28.0) with `redirect: 'manual'`, AbortController timeouts and a capped body reader (`readTextCapped`) — same approach here, but WITHOUT the private-IP filter (L-03: internal hosts allowed).
- Magic bytes: `detectImageMime(buffer)` in `apps/api/src/dashboard/dashboard-image-rules.ts` (PNG/JPEG/GIF/WebP). Serving pattern for uploaded images: `apps/api/src/favorites/favorites.controller.ts` `getIcon` (Content-Type from detected mime, `Cache-Control: private, max-age=86400`, `X-Content-Type-Options: nosniff`, `Content-Security-Policy: default-src 'none'; sandbox`) and `FileInterceptor(field, { limits: { fileSize, files: 1 } })`. The web loads authenticated images through the same-origin proxy `/api-proxy/<api path>` with a `?v=<version>` cache buster (favorites-widget.tsx).
- RLS gates: `rls-coverage.spec.ts` needs the policies in the migration; `rls-access-inventory.spec.ts` compares every (file, model) Prisma access against the Fundstellentabelle in `docs/mandantentrennung-zugriffsklassifikation.md` (plus Bereichszeile, Summenzeile, Paarzählung — follow the quick-261002-fm5 and proxmox rows) and allows `forSystem(` only at the call sites listed in `FORSYSTEM_ALLOWED_CALL_SITES`. A file with tenant-bound AND one system read on the same model has Stand `system-gebunden` (precedent `proxmox.service.ts`/`proxmoxServer`). Never use `include:` or relation `select:` in this module.
- Status colors: tokens `bg-status-ok|warn|down|idle` and `text-status-*-fg`, pill form `bg-status-ok/12 text-status-ok-fg` (see `apps/web/src/components/proxmox/status-styles.ts`); class strings must be literal (Tailwind scanning).
- Module routes in the web: `/modules/nextcloud-status` (own layout with `ModuleAccessGate`) AND the sidebar route `/modules/infrastructure/nextcloud-status` (generic `[category]/[moduleSlug]` page → `module-loader.ts`).
- i18n: widget catalog names live under `widgets.<key>.name/description` in de.json/en.json; module texts get a new top-level namespace `nextcloudStatus`. `apps/web/src/messages/umlaut-guard.spec.ts` rejects ae/oe/ue/ss tokens in de.json that are not in `UMLAUT_ALLOWLIST` (e.g. "aktuell", "Neueste" may need allowlisting — run the test).
- Widget tests enumerate all widget types (currently eleven): `widget-registry.test.tsx`, `widget-catalog-modal.test.tsx`, `apps/api/src/dashboard/widget-module-map.spec.ts`; `(portal)/page.test.tsx` mocks each widget module.
- endoflife.date sample (2026-10-02): `[{"cycle":"35","releaseDate":"2026-09-16","eol":"2027-09-30","latest":"35.0.1",...},{"cycle":"34","eol":"2027-06-30","latest":"34.0.4"},{"cycle":"33","eol":"2027-02-28","latest":"33.0.9"},{"cycle":"32","eol":"2026-09-30","latest":"32.0.15"},...]`; `eol` may also be a boolean. Nextcloud `status.php` returns `installed, maintenance, needsDbUpgrade, version ("31.0.5.1"), versionstring ("31.0.5"), edition, productname, extendedSupport`.
- Migration convention: hand-written SQL with German header comment (model: `20260923140000_proxmox_server`); latest existing migration is `20261002140000_module_grant_level`. Local DB: no host port — IP via `docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1`, then `DATABASE_URL="postgresql://tessera:tessera_dev@<IP>:5432/tessera" pnpm --filter @tessera/api exec prisma migrate deploy`.
- Commits: German subject, conventional prefix, end with `Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>`. Never push. PLAN/SUMMARY/STATE are committed by the orchestrator, not by the executor.
@apps/api/src/proxmox/proxmox.controller.ts
@apps/api/src/proxmox/proxmox-scheduler.service.ts
@apps/api/src/proxmox/proxmox.seed.ts
@apps/api/prisma/migrations/20260923140000_proxmox_server/migration.sql
@apps/web/src/app/(portal)/modules/proxmox/page.tsx
@apps/web/src/components/dashboard/widgets/proxmox-widget.tsx
</context>
<tasks>
<task type="tracer">
<name>Task 1: Tracer — a registered cloud is checked, rated and shown as a tile (DB → status.php → endoflife reference → rating → GET instances → module page)</name>
<files>apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20261002150000_nextcloud_status/migration.sql, apps/api/src/nextcloud-status/nextcloud-rating.ts, apps/api/src/nextcloud-status/nextcloud-rating.spec.ts, apps/api/src/nextcloud-status/nextcloud-status-fetch.ts, apps/api/src/nextcloud-status/nextcloud-status-fetch.spec.ts, apps/api/src/nextcloud-status/nextcloud-release.service.ts, apps/api/src/nextcloud-status/nextcloud-release.service.spec.ts, apps/api/src/nextcloud-status/nextcloud-status.service.ts, apps/api/src/nextcloud-status/nextcloud-status.service.spec.ts, apps/api/src/nextcloud-status/nextcloud-status.controller.ts, apps/api/src/nextcloud-status/nextcloud-status.controller.spec.ts, apps/api/src/nextcloud-status/dto/nextcloud-instance.dto.ts, apps/api/src/nextcloud-status/nextcloud-status.seed.ts, apps/api/src/nextcloud-status/nextcloud-status.module.ts, apps/api/src/app.module.ts, docs/mandantentrennung-zugriffsklassifikation.md, apps/web/src/lib/nextcloud-status-api.ts, apps/web/src/components/nextcloud-status/rating-display.ts, apps/web/src/app/(portal)/modules/nextcloud-status/layout.tsx, apps/web/src/app/(portal)/modules/nextcloud-status/page.tsx, apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudTile.tsx, apps/web/src/app/(portal)/modules/nextcloud-status/nextcloud-status-page.test.tsx, apps/web/src/app/(portal)/modules/module-layouts.test.tsx, apps/web/src/lib/module-loader.ts, apps/web/src/lib/module-identity.ts, apps/web/src/lib/stores/nav-store.ts, apps/web/src/components/modules/module-tile.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts</files>
<behavior>
- rateNextcloud (reference = 35/2027-09-30/35.0.1, 34/2027-06-30/34.0.4, 33/2027-02-28/33.0.9, 32/2026-09-30/32.0.15, 31/2026-02-28/31.0.14; now = 2026-10-02 unless stated): 35.0.1 → green/current; 35.0.2 (newer than listed latest) → green/current; 34.0.3 → yellow/update-available, updateTo 34.0.4; 32.0.15 → red/eol-passed with eolDate 2026-09-30; 32.0.15 at now 2026-08-01 → yellow/eol-soon; 33.0.9 at 2026-12-15 → yellow/eol-soon eolDate 2027-02-28; boundary: EOL exactly 90 days ahead → yellow, 91 days → green; EOL day itself → yellow, the day after → red; 33.0.5 at 2026-12-15 → yellow, reason eol-soon AND updateTo 33.0.9; major 20 (older than all listed) → red/eol-passed without date; major 36 (newer than all) → green/current; cycle with eol false → supported, green when latest; eol true → red/eol-passed without date; reachable false → red/unreachable even with reference null; errorKind not-nextcloud → red/invalid-response; maintenance true → red/maintenance; needsDbUpgrade true → red/needs-db-upgrade; red priority unreachable > invalid-response > maintenance > needs-db-upgrade > eol-passed; reference null + reachable → unknown/no-reference; reachable but no parseable version → unknown/version-unknown; never checked → unknown/not-checked; newestVersion(reference) = latest of the highest cycle ('35.0.1').
- normalizeCloudUrl: ' https://cloud.kunde.de/ ' → 'https://cloud.kunde.de'; '.../status.php' and '.../index.php' suffix stripped; subpath 'https://host/nextcloud/' → 'https://host/nextcloud'; query/hash dropped; 'http://intern:8080' kept; ftp:, javascript:, URL with user:pass@, empty, longer than 2048 → null.
- parseNextcloudStatus: valid JSON → fields; maintenance true kept; needsDbUpgrade missing → false; versionstring missing → first three parts of version; HTML, empty, array, installed false, non-object → null; overlong edition/productname cut to 64 chars.
- fetchNextcloudStatus (injected fetch): 200 valid → reachable true + fields; 200 maintenance → reachable true, maintenance true; 200 garbage → reachable false, errorKind not-nextcloud; 503 → errorKind http-status, errorDetail 'HTTP 503'; never-resolving fetch → errorKind timeout after the injected timeout (fake timers or a 20 ms timeout); rejected fetch with cause.code ENOTFOUND → errorKind network, detail 'ENOTFOUND'; cert error code (e.g. CERT_HAS_EXPIRED) → errorKind tls; 301 with relative Location → followed once and succeeds; redirect to ftp: → errorKind redirect; 4 consecutive redirects → errorKind redirect; body over 64 KiB → errorKind too-large; request carries no Authorization/Cookie header and targets exactly '<base>/status.php'.
- NextcloudReleaseService (injected fetch + clock): first getReference fetches and parses; second call within 12 h does not fetch; after 12 h returns the stale data immediately and refreshes in the background; failure with existing data keeps it; failure without data → null; no new attempt within 15 min after a failure; garbage/empty array keeps last good; parallel calls share one request; parseEndOfLife skips entries without numeric cycle or valid latest.
- NextcloudStatusService (mocked prisma via forTenant, mocked fetch + release): createInstance normalizes the URL (invalid → BadRequestException with German message), creates the row and runs the first check; checkInstance writes reachable/maintenance/needsDbUpgrade/versionString/edition/errorKind/errorDetail/lastCheckedAt; checkInstance for an unknown id → NotFoundException; listForTenant selects no logo bytes, returns { instances (with rating), reference: { newestVersion, fetchedAt } }.
- Controller metadata: class has MODULE_SLUG_KEY 'nextcloud-status' + ModuleGuard; `list` has no MODULE_MANAGE_KEY; `create` and `checkOne` have MODULE_MANAGE_KEY true and no ROLES_KEY.
- Web page test: with a mocked list (one green, one yellow with updateTo, one red unreachable, one grey) it renders four tiles with Kundenname, version, the translated reasons ('Aktuell', 'Update auf 34.0.4 verfügbar', 'Nicht erreichbar', 'Bewertung nicht möglich'), the URL as link with target _blank and rel noopener noreferrer, and the line with the newest Nextcloud version; an empty list shows the empty state.
</behavior>
<action>
**Schema + migration (L-01, L-02, L-03, D-A).** In `apps/api/prisma/schema.prisma` add `model NextcloudInstance` after `ProxmoxServerStatus` with a German comment (quick-261002-k67; status lives on the row, L-03; logo bytes per D-A; no relation to Tenant, pattern ProxmoxServer): `id String @id @default(uuid())`, `tenantId String`, `customerName String`, `baseUrl String`, `logoUrl String?`, `logoData Bytes?`, `logoMime String?`, `logoVersion Int @default(0)`, `lastCheckedAt DateTime?`, `reachable Boolean?` (null = never checked), `maintenance Boolean?`, `needsDbUpgrade Boolean?`, `versionString String?`, `edition String?`, `productName String?`, `errorKind String?`, `errorDetail String?`, `createdAt`, `updatedAt @updatedAt`, `@@index([tenantId])`. Hand-write `apps/api/prisma/migrations/20261002150000_nextcloud_status/migration.sql` in the style of 20260923140000_proxmox_server: German header (purpose; tenant_isolation_policy WITHOUT user dimension because clouds are shared data of the organisation; `system_read_policy ... FOR SELECT USING (is_system_context())` because the hourly job of Task 2 reads id/tenantId of all rows; rights via ALTER DEFAULT PRIVILEGES; switch-is-off note), CREATE TABLE with `"logoData" BYTEA`, index, ENABLE + FORCE ROW LEVEL SECURITY, both policies. Run `pnpm --filter @tessera/api exec prisma generate`; apply locally (L-14) with the container-IP command from the context and confirm `prisma migrate status` is up to date and `prisma migrate diff --from-url "$DATABASE_URL" --to-schema-datamodel prisma/schema.prisma --exit-code` exits 0.
**Pure functions (L-03, L-05, D-E, D-F).** `nextcloud-rating.ts` (no Nest, no Prisma): types `NextcloudCycle { cycle: number; eol: string | boolean; latest: string }`, `NextcloudReference { cycles: NextcloudCycle[]; fetchedAt: string }`, `RatingLevel = 'green'|'yellow'|'red'|'unknown'`, `RatingReason = 'current'|'update-available'|'eol-soon'|'eol-passed'|'unreachable'|'invalid-response'|'maintenance'|'needs-db-upgrade'|'no-reference'|'version-unknown'|'not-checked'`, `NextcloudRating { level; reason; updateTo: string|null; eolDate: string|null; cycle: number|null }`. Export `rateNextcloud(status, reference, now: Date)` and `newestVersion(reference)`. Compare dates as UTC calendar days ('YYYY-MM-DD' of `now`), EOL-soon window `EOL_WARNING_DAYS = 90` inclusive, version compare numerically on three parts parsed with an anchored regex from versionString (fallback: first three parts of `version`). Implement exactly the cases in `<behavior>`; German JSDoc explaining each rule and citing L-05. `nextcloud-status-fetch.ts`: `normalizeCloudUrl(raw)`, `parseNextcloudStatus(text)` (JSON.parse in try, whitelist fields, version strings limited to digits/dots and 32 chars), and `fetchNextcloudStatus(baseUrl, opts?: { fetchImpl?, timeoutMs? })` → `{ reachable, maintenance, needsDbUpgrade, versionString, edition, productName, errorKind, errorDetail }`. Use `undiciFetch` by default, `redirect: 'manual'`, at most `MAX_REDIRECTS = 3` hops (each Location resolved against the current URL, only http/https), one AbortController for the whole request with `STATUS_TIMEOUT_MS = 10_000`, headers only `Accept: application/json` and a `User-Agent: Tessera-Nextcloud-Status`, body read through a capped reader with `MAX_STATUS_BYTES = 64 * 1024`, discard bodies of redirect/error responses. errorKind values: timeout, network, tls, http-status, not-nextcloud, too-large, redirect; errorDetail is only our own short code ('HTTP 503', the `cause.code` such as ENOTFOUND/ECONNREFUSED/CERT_HAS_EXPIRED, max 120 chars) — never a response body or exception message. Put a German SSRF comment block above the function (L-03): manager-entered URLs incl. internal hosts are allowed on purpose like Proxmox (T-DHH-02); limits applied (only `/status.php`, GET, no credentials, 3 redirects, 10 s, 64 KiB, only whitelisted fields leave the server); a person with Verwalten could thereby learn whether an internal address answers — accepted.
**Reference cache (L-04, D-D).** `nextcloud-release.service.ts`: `@Injectable() NextcloudReleaseService` without constructor parameters; test seams are two instance fields `fetchImpl` (default `undiciFetch`) and `now` (default `() => Date.now()`) that specs overwrite on the instance. Export pure `parseEndOfLife(json): NextcloudCycle[]` (cycle must be /^\d+$/, latest a dotted version, eol a 'YYYY-MM-DD' string or boolean; invalid entries skipped). `getReference(): Promise<NextcloudReference | null>` and `refresh(): Promise<void>` per D-D: source URL constant `ENDOFLIFE_URL = 'https://endoflife.date/api/nextcloud.json'`, `CACHE_TTL_MS = 12 h`, `RETRY_BACKOFF_MS = 15 min`, 10 s timeout, 512 KiB cap, empty parse result counts as failure, failures logged once per attempt with Logger.warn.
**Service + DTO + controller + seed + module (L-01, L-09).** `dto/nextcloud-instance.dto.ts`: `CreateNextcloudInstanceDto { customerName (IsString, IsNotEmpty, MaxLength 120); baseUrl (IsString, IsNotEmpty, MaxLength 2048); logoUrl? (IsOptional, ValidateIf non-empty, IsUrl https only with require_protocol, MaxLength 2048) }` and `UpdateNextcloudInstanceDto` with all three optional (same validators; empty logoUrl = remove). `nextcloud-status.service.ts`: inject PrismaService and NextcloudReleaseService; a module-level `PUBLIC_SELECT` constant without `logoData`; every method uses its own `const tenantPrisma = forTenant(this.prisma, tenantId)`; `listForTenant(tenantId)` (findMany ordered by customerName, maps to a view `{ id, customerName, baseUrl, logoUrl, hasUploadedLogo, logoVersion, status: { checkedAt, reachable, maintenance, needsDbUpgrade, versionString, edition, errorKind, errorDetail }, rating }` using `rateNextcloud(..., reference, new Date())`, returns `{ instances, reference: { newestVersion, fetchedAt } }`); `createInstance(tenantId, dto)` (normalizeCloudUrl → BadRequestException 'Bitte geben Sie eine gültige Adresse mit http:// oder https:// ein.'; create; then `checkInstance`); `checkInstance(tenantId, id)` (findFirst by id+tenantId → NotFoundException; fetchNextcloudStatus; update status columns + lastCheckedAt; return the view). `nextcloud-status.controller.ts`: `@Controller('modules/nextcloud-status')`, class `@UseModule('nextcloud-status')`, `requireTenantId` as in ProxmoxController; `@Get('instances') list`; `@Post('instances') @ModuleManage('nextcloud-status') create`; `@Post('instances/:id/check') @ModuleManage('nextcloud-status') checkOne`. Header comment: rights per L-09, route order rule (static routes before `:id`, see Task 2). `nextcloud-status.seed.ts` like proxmox.seed.ts: slug 'nextcloud-status', name 'Nextcloud-Status', version '1.0.0', category 'infrastructure', description de 'Versionen und Erreichbarkeit Ihrer Nextcloud-Clouds im Blick' / en 'Keep track of versions and availability of your Nextcloud clouds', isSystem true. `nextcloud-status.module.ts` like ProxmoxModule (imports ModuleRegistryModule, providers NextcloudStatusService + NextcloudReleaseService, OnModuleInit seed with log line 'Nextcloud-Status module seeded in registry'). Register `NextcloudStatusModule` in `app.module.ts` right after ProxmoxModule. Specs per `<behavior>` (controller spec pattern: Reflect.getMetadata on prototype methods, like module-manage-handlers.spec.ts).
**RLS inventory doc.** Run `pnpm --filter @tessera/api exec vitest run rls-coverage rls-access-inventory`; add the Bereichszeile `nextcloud-status`, update the Summenzeile and Paarzählung, and add the Fundstellentabelle row(s) for `apps/api/src/nextcloud-status/nextcloud-status.service.ts` / `nextcloudInstance` (Stand `gebunden` for now; Task 2 turns it into `system-gebunden`) in `docs/mandantentrennung-zugriffsklassifikation.md`, counted with the Gate-Schleife exactly as the fm5 rows describe; both specs green.
**Web tracer (L-06, L-11).** `apps/web/src/lib/nextcloud-status-api.ts` (pattern proxmox-api.ts, `credentials: 'include'`): exported types mirroring the API view and `listInstances()`; plus `logoSrc(instance)` returning `/api-proxy/modules/nextcloud-status/instances/<id>/logo?v=<logoVersion>` when `hasUploadedLogo`, else `logoUrl`, else null. `apps/web/src/components/nextcloud-status/rating-display.ts`: literal class map `RATING_STYLE` per level (green→status-ok, yellow→status-warn, red→status-down, unknown→status-idle; fill + pill + text), `ratingReasonText(t, rating, locale)` mapping reason → key under `nextcloudStatus.reason.*` with params (version, date formatted with Intl.DateTimeFormat for the locale in UTC, e.g. 30.06.2027), shared later by the widget. `layout.tsx` = ModuleAccessGate moduleSlug "nextcloud-status" (copy proxmox/layout.tsx). `page.tsx` ('use client'): PageHeader with title, a muted line "Neueste Nextcloud-Version: {version}" (or "Versionsdaten derzeit nicht verfügbar"), loading skeleton, empty state, responsive tile grid (`grid gap-4 sm:grid-cols-2 xl:grid-cols-3`) of `CloudTile`. `CloudTile.tsx`: colored left strip + status pill with reason, logo (img with `referrerPolicy="no-referrer"`, alt = Kundenname, fallback initials on null or onError), Kundenname, URL link (`target="_blank" rel="noopener noreferrer"`), installed version (or "—"), last check as relative time ("vor 12 Min.", "noch nie"), errorDetail in small muted text only when red/unreachable. Use existing tokens (bg-card, text-muted-foreground, dark: variants as in proxmox ServerCard) — no new colors. Registrations: module-loader.ts entry 'nextcloud-status' (dynamic import of the page, ssr false); module-identity.ts new ModuleIconId 'cloud' mapped from 'nextcloud-status'; module-tile.tsx ICONS 'cloud' (lucide cloud path `M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z`); nav-store.ts MODULE_TITLE_KEYS 'nextcloud-status' → 'nextcloudStatus.title'; module-layouts.test.tsx add ['nextcloud-status', NextcloudStatusLayout] to the it.each. Messages: new top-level `nextcloudStatus` namespace in de.json (formal Sie, real umlauts) and en.json with identical keys: title, newestVersion, referenceUnavailable, empty, lastCheck/never, version labels, and `reason.{current: 'Aktuell', updateAvailable: 'Update auf {version} verfügbar', eolSoon: 'Support endet am {date}', eolPassed: 'Support abgelaufen seit {date}', eolPassedNoDate: 'Support abgelaufen', unreachable: 'Nicht erreichbar', invalidResponse: 'Keine gültige Nextcloud-Antwort', maintenance: 'Wartungsmodus', needsDbUpgrade: 'Datenbank-Aktualisierung ausstehend', noReference: 'Bewertung nicht möglich', versionUnknown: 'Version unbekannt', notChecked: 'Noch nicht geprüft'}` plus English equivalents ('Up to date', 'Update to {version} available', 'Support ends on {date}', …). UI texts never name the tenant concept (L-11). Run the umlaut guard and add legitimately correct tokens to `UMLAUT_ALLOWLIST` only if it fails. Page test per `<behavior>` (mock `@/lib/nextcloud-status-api` and next-intl like the proxmox page tests).
Biome-lint the touched files (`pnpm exec biome lint <files>` from repo root, `biome check --write` on new files only), commit `feat(nextcloud-status): Modul mit Statusabruf, Versionsbewertung und Kachelansicht` (attribution line). Do not push.
</action>
<verify>
<automated>pnpm --filter @tessera/api exec vitest run src/nextcloud-status rls-coverage rls-access-inventory && pnpm --filter @tessera/web exec vitest run nextcloud-status module-layouts umlaut && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit</automated>
</verify>
<done>NextcloudInstance migration applied locally without drift; rating, parser, fetch, release cache, service and controller specs green; GET /modules/nextcloud-status/instances returns rated instances plus newest version; the module page renders the tiles with translated reasons; module registered in loader, identity, tile icon, nav titles and layouts test; RLS gates green; commit on main, not pushed.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Managers maintain clouds (edit, delete, logo), trigger checks, hourly job runs; everyone can sort the tiles</name>
<files>apps/api/src/nextcloud-status/nextcloud-logo-rules.ts, apps/api/src/nextcloud-status/nextcloud-logo-rules.spec.ts, apps/api/src/nextcloud-status/nextcloud-status.service.ts, apps/api/src/nextcloud-status/nextcloud-status.service.spec.ts, apps/api/src/nextcloud-status/nextcloud-status.controller.ts, apps/api/src/nextcloud-status/nextcloud-status.controller.spec.ts, apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.ts, apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.spec.ts, apps/api/src/nextcloud-status/nextcloud-status.module.ts, apps/api/src/module-registry/module-manage-handlers.spec.ts, apps/api/src/prisma/rls-access-inventory.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md, apps/web/src/lib/nextcloud-status-api.ts, apps/web/src/components/nextcloud-status/sort-clouds.ts, apps/web/src/components/nextcloud-status/sort-clouds.test.ts, apps/web/src/app/(portal)/modules/nextcloud-status/page.tsx, apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudTile.tsx, apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.tsx, apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.test.tsx, apps/web/src/app/(portal)/modules/nextcloud-status/nextcloud-status-page.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts</files>
<behavior>
- checkLogoUpload (nextcloud-logo-rules): PNG/JPEG/GIF/WebP bytes ≤ 1 MiB → detected mime; SVG text, PDF, empty, renamed HTML → rejected; 1 MiB + 1 byte → rejected (second net behind multer).
- Service: updateInstance changes name/URL (new URL normalized and re-checked; unchanged URL not re-checked); non-empty logoUrl clears logoData/logoMime and bumps logoVersion; empty logoUrl sets null; uploadLogo stores bytes + detected mime, clears logoUrl, bumps logoVersion; removeLogo clears bytes/mime and bumps logoVersion; getLogo returns { data, mime } or NotFoundException when none; deleteInstance removes the row; every write/read of a foreign id → NotFoundException (where id + tenantId); checkAllForTenant checks all instances of the tenant with at most 4 in parallel and returns the refreshed list; loadAllInstancesForScheduler selects only id and tenantId through forSystem.
- Scheduler: onApplicationBootstrap registers exactly one cron job 'nextcloud-status-poll' with '0 * * * *' without any database read, starts it, and fires one non-blocking release refresh; a thrown error during bootstrap is logged and does not propagate; tick groups instances by tenant and calls checkInstance(tenantId, id) for each; one failing instance does not stop the others; a tick started while the previous one runs is skipped.
- Controller metadata + order: list and logo (GET) have no MODULE_MANAGE_KEY; create, update, remove, checkAll, checkOne, uploadLogo, removeLogo have MODULE_MANAGE_KEY true and no ROLES_KEY; the static handler for POST 'instances/check' is declared before every handler whose path contains ':id' (index check on Object.getOwnPropertyNames of the prototype).
- sortClouds: 'name' → A–Z with German collation (Ä next to A, case-insensitive); 'status' → red, yellow, unknown, green, ties by name; 'version' → oldest first, missing version last; 'eol' → earliest eolDate first, missing last; input array not mutated. readSortPreference/writeSortPreference: key 'tessera:nextcloud-status:sort:<userId>', unknown stored value → 'name', throwing localStorage → default and no throw.
- Page: USE user (useCanManageModule false) sees tiles and the sort selector but no "Cloud hinzufügen", "Jetzt prüfen", per-tile refresh/edit buttons; manager (true) sees all of them; choosing 'Status' reorders tiles red first and writes the preference; a stored preference is applied on load; "Jetzt prüfen" calls checkAll and replaces the list.
- CloudForm: required Kundenname and URL; logo choice "Keins / Bild hochladen / Bildadresse"; https-only hint on logo URL; submit calls create (or update) and, when a file is chosen, uploadLogo afterwards; delete asks for confirmation before calling remove; API error message is shown in the form.
</behavior>
<action>
**API write paths (L-02, L-09, D-A).** `nextcloud-logo-rules.ts`: `NEXTCLOUD_LOGO_MAX_BYTES = 1024 * 1024`, `checkLogoUpload(buffer)` → mime or null, built on `detectImageMime` from `../dashboard/dashboard-image-rules` (German comment: no SVG because inline script; magic bytes instead of client mimetype, pattern T-PI9-01). Extend `nextcloud-status.service.ts` with `updateInstance`, `deleteInstance`, `uploadLogo(tenantId, id, file)` (BadRequestException 'Bitte laden Sie ein Bild im Format PNG, JPEG, GIF oder WebP bis 1 MB hoch.'), `getLogo`, `removeLogo`, `checkAllForTenant` (small promise pool of 4, each instance isolated with try/catch + Logger.warn), `listInstanceIdsForTenant`, and `loadAllInstancesForScheduler()` — the single `const systemPrisma = forSystem(this.prisma);` access, `select: { id: true, tenantId: true }`, German comment naming FORSYSTEM_ALLOWED_CALL_SITES and the system_read_policy of migration 20261002150000. All per-row writes stay `forTenant` with `where: { id, tenantId }`.
**Controller routes (L-08, L-09, L-12).** Final handler order in `nextcloud-status.controller.ts`: `@Get('instances') list`; `@Post('instances') create`; `@Post('instances/check') checkAll` (static, BEFORE any `:id` route); `@Put('instances/:id') update`; `@Delete('instances/:id') remove`; `@Post('instances/:id/check') checkOne`; `@Get('instances/:id/logo') logo` (USE level; sets Content-Type from the stored mime, `Cache-Control: private, max-age=86400`, `X-Content-Type-Options: nosniff`, `Content-Security-Policy: default-src 'none'; sandbox`, sends the bytes — pattern favorites getIcon); `@Post('instances/:id/logo')` with `FileInterceptor('logo', { limits: { fileSize: NEXTCLOUD_LOGO_MAX_BYTES, files: 1 } })` uploadLogo; `@Delete('instances/:id/logo') removeLogo`. Every write/check handler gets `@ModuleManage('nextcloud-status')` and no role decorator. Extend the controller spec with the metadata and declaration-order assertions, and add a `NextcloudStatusController` block to `apps/api/src/module-registry/module-manage-handlers.spec.ts` (manage handlers listed via it.each, list/logo stay USE level).
**Scheduler (L-08, D-C).** `nextcloud-status-scheduler.service.ts` implementing `OnApplicationBootstrap` (German header: why not OnModuleInit — Tender bootstrap lesson; why a single global job instead of per-tenant jobs — fixed hourly interval, no per-row setting, no DB read at registration). Reuse the `require('cron').CronJob` workaround and the SchedulerRegistry calls from ProxmoxSchedulerService; job name `nextcloud-status-poll`, cron `0 * * * *`; `running` flag as overlap guard; tick = `loadAllInstancesForScheduler()` → group by tenantId → all instances of the tick run through one pool of at most 4 concurrent `checkInstance(tenantId, id)` calls (each bound to its own tenantId), every error logged with tenant and id. Bootstrap also calls `void this.release.refresh()` (caught). Register the scheduler in `nextcloud-status.module.ts` providers. Spec per `<behavior>` with mocked SchedulerRegistry (pattern proxmox-scheduler.service.spec.ts).
**RLS gates.** Add `['apps/api/src/nextcloud-status/nextcloud-status.service.ts', 1]` to `FORSYSTEM_ALLOWED_CALL_SITES` in `rls-access-inventory.spec.ts`, extending its history comment (quick-261002-k67: hourly job reads id/tenantId of all instances, writes per row tenant-bound; new totals). Update `docs/mandantentrennung-zugriffsklassifikation.md`: Bereichszeile counts (bound/system), Summenzeile, Fundstellentabelle row for `nextcloud-status.service.ts`/`nextcloudInstance` now `system-gebunden` with the explanation (precedent proxmox row); recount with the Gate-Schleife, never copy numbers.
**Web (L-02, L-07, L-09, D-G).** `nextcloud-status-api.ts`: add `createInstance`, `updateInstance`, `deleteInstance`, `checkAll`, `checkOne`, `uploadLogo` (FormData field 'logo'), `removeLogo`; non-ok responses throw an Error carrying the API `message` (pattern proxmox-api.ts). `sort-clouds.ts`: `SortKey = 'name'|'status'|'version'|'eol'`, `sortClouds(items, key)` per D-G (pure, returns a new array, `localeCompare(…, 'de', { sensitivity: 'base' })`), `readSortPreference(userId)` / `writeSortPreference(userId, key)` with key `tessera:nextcloud-status:sort:<userId>`, everything in try/catch, unknown values fall back to 'name'. Page: sort `<select>` with the four options (label "Sortieren nach"), applied via useMemo; user id from `useAuthStore`; `const canManage = useCanManageModule('nextcloud-status') === true` gates "Cloud hinzufügen", "Jetzt prüfen" (spinner while running, then replace list with the response) and, on each tile, refresh (checkOne, replaces that tile) and edit (opens CloudForm). `CloudForm.tsx`: dialog/panel (follow the proxmox ServerForm look) for add/edit with Kundenname, URL (placeholder https://cloud.example.com), logo choice radio "Kein Logo / Bild hochladen / Bildadresse (https)", file input accept="image/png,image/jpeg,image/gif,image/webp", client-side size hint 1 MB, preview of the current logo, "Logo entfernen", delete button with confirmation dialog ("Möchten Sie die Cloud „{name}“ wirklich entfernen?"), saving state "Wird geprüft …" (create awaits the first check), API errors shown inline. All new texts in `nextcloudStatus.*` de + en, formal Sie, real umlauts, no tenant wording; umlaut guard green. Tests per `<behavior>`: `sort-clouds.test.ts`, `CloudForm.test.tsx`, and new manager/USE/sorting cases in `nextcloud-status-page.test.tsx` (mock `@/lib/use-module-capability`).
Biome-lint touched files, commit `feat(nextcloud-status): Clouds verwalten, Logos, stündliche Prüfung und Sortierung` (attribution line). Do not push.
</action>
<verify>
<automated>pnpm --filter @tessera/api exec vitest run src/nextcloud-status src/module-registry rls-coverage rls-access-inventory && pnpm --filter @tessera/web exec vitest run nextcloud-status umlaut && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && test -z "$(grep -nE '^\s*@Roles\(' apps/api/src/nextcloud-status/nextcloud-status.controller.ts)"</automated>
</verify>
<done>All write, logo and check routes exist behind ModuleManage (metadata + route-order specs green); the hourly job is registered in onApplicationBootstrap without a DB read and checks every instance tenant-bound; forSystem allowlist and RLS doc updated; the page offers sorting for everyone (remembered per user) and add/edit/delete/logo/check controls only to managers; commit on main, not pushed.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Dashboard tile "Nextcloud-Status", docs and changelog, full suites, local rebuild</name>
<files>packages/shared/src/index.ts, apps/api/src/dashboard/widget-module-map.ts, apps/api/src/dashboard/widget-module-map.spec.ts, apps/web/src/components/dashboard/widget-registry.tsx, apps/web/src/components/dashboard/widget-registry.test.tsx, apps/web/src/components/dashboard/widget-catalog-modal.test.tsx, apps/web/src/components/dashboard/widgets/widget-icon.tsx, apps/web/src/components/dashboard/widgets/widget-wrapper.tsx, apps/web/src/components/dashboard/widgets/nextcloud-status-widget.tsx, apps/web/src/components/dashboard/widgets/nextcloud-status-widget.test.tsx, apps/web/src/app/(portal)/page.tsx, apps/web/src/app/(portal)/page.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts, CHANGELOG.md, docs/anleitung-anwender.md, docs/anleitung-administration.md</files>
<behavior>
- WIDGET_TYPES ends with 'nextcloud-status' (twelve types, existing order unchanged); WIDGET_MODULE_SLUGS maps proxmox → proxmox and 'nextcloud-status' → 'nextcloud-status'; getModuleSlugForWidgetType('nextcloud-status') = 'nextcloud-status'; all other types stay platform tiles.
- Catalog: without module access neither Proxmox nor Nextcloud-Status appears; with access to 'nextcloud-status' only that module tile is added.
- Widget: list with 2 green, 1 yellow (updateTo 34.0.4), 1 red (maintenance) → counters 2/1/1, list shows the red cloud first, then the yellow one, each with name + translated reason; all green → a calm "Alle Clouds sind aktuell" line and no list; empty list → hint "Noch keine Cloud eingetragen"; view mode rows link to /modules/nextcloud-status; edit mode rows are not links; the widget never calls a check endpoint (only listInstances); unknown-rated clouds counted separately only when > 0.
</behavior>
<action>
**Widget registration (L-10).** `packages/shared/src/index.ts`: append `'nextcloud-status'` to WIDGET_TYPES and add `'nextcloud-status': 'nextcloud-status'` to WIDGET_MODULE_SLUGS (extend the comment: second module tile, quick-261002-k67; slug equals the seed and the class-level UseModule). Update the comment in `apps/api/src/dashboard/widget-module-map.ts` (no longer exactly one entry) and `widget-module-map.spec.ts` (module tiles = proxmox and nextcloud-status). `widget-registry.tsx`: WIDGET_CONSTRAINTS `'nextcloud-status': { minW: 6, minH: 4, defaultW: 12, defaultH: 8 }` with a comment, an inline `NextcloudStatusIcon` (cloud path from Task 1), and the registry entry (nameKey 'nextcloudStatus.name', descriptionKey 'nextcloudStatus.description', moduleSlug from WIDGET_MODULE_SLUGS, PlaceholderWidget). `widget-icon.tsx`: 'nextcloud-status' cloud glyph. `widget-wrapper.tsx`: add 'nextcloud-status' to FRAME_HEADER_TYPES (frame header = icon + name, hide-title toggle via TITLE_TYPES) and update its comment. `(portal)/page.tsx`: import and `registerWidget('nextcloud-status', NextcloudStatusWidget)`; add the matching vi.mock in `page.test.tsx`. Update the enumerations in `widget-registry.test.tsx` (twelve types, constraints table, moduleSlug assertion for both module tiles, visibleWidgetTypes cases) and `widget-catalog-modal.test.tsx` (counts and module-access cases).
**Widget component (L-10, L-11).** `nextcloud-status-widget.tsx` ('use client', WidgetProps): reads only `listInstances()` from `@/lib/nextcloud-status-api` (German comment: never triggers checks — pattern T-I8V-02), refresh every 5 min with visibility pause and immediate reload on return (pattern proxmox-widget.tsx); three counter chips using `RATING_STYLE` from `rating-display.ts` (grün/gelb/rot labels plus "ohne Bewertung" only when > 0); below, red then yellow clouds sorted by name (`sortClouds(items, 'status')` from Task 2) with status dot, Kundenname and `ratingReasonText`; container-query compact mode like the proxmox widget (narrow: only counters); in view mode each row and the counter area are Next `Link`s to `/modules/nextcloud-status`, in edit mode plain rows without tabstop (links are in the drag-cancel selector, see proxmox comment); loading, error ("Status konnte nicht geladen werden") and empty states. Messages: `widgets.nextcloudStatus.{name: 'Nextcloud-Status', description: 'Ampelübersicht Ihrer Nextcloud-Clouds'}` and `nextcloudStatus.widget.*` texts in de + en. Widget test per `<behavior>`.
**Docs + changelog (L-13).** `CHANGELOG.md` under "## Unveröffentlicht" → "### Neu": one user-facing German bullet (module in Infrastruktur, Aktivierung im Marktplatz + Freigabe, tiles with Ampel and what the colors mean, newest version shown, hourly check plus "Jetzt prüfen", sorting remembered, logo upload or address, who may maintain clouds = Verwalten, dashboard tile). `docs/anleitung-anwender.md`: new section "### Nextcloud-Status" after "### Proxmox" (what the tile shows, the exact traffic-light rules in plain words incl. 3-month window, grey state, sorting, the dashboard tile, what Verwalten users can do). `docs/anleitung-administration.md`: new subsection after "### Proxmox-Server anbinden…" — "### Nextcloud-Status: Clouds eintragen" (who may maintain, Tessera reads only the public status.php, no login data needed, the API container needs outbound access to the clouds and to endoflife.date, internal addresses allowed, certificate must be valid, logo rules 1 MB PNG/JPEG/GIF/WebP or https address loaded by the browser), plus its entry in the table of contents if the section list there names subsections. No tenant wording in user-facing docs/changelog.
**Final gates (L-12, L-14).** Run full `pnpm --filter @tessera/api test` and `pnpm --filter @tessera/web test`, both tsc, biome lint on all touched files of the three tasks; fix any enumeration test that still expects eleven widget types. Rebuild `docker compose up -d --build api web`, wait for healthy, check `docker compose logs api` for 'Nextcloud-Status module seeded in registry', the mapped routes `/modules/nextcloud-status/instances`, the scheduler log line, and no migration errors; confirm with psql in the db container that the `Module` row `nextcloud-status` has category `infrastructure`. Commit `feat(nextcloud-status): Dashboard-Kachel, Anleitung und Changelog` (attribution line). Do not push; leave the browser check to the orchestrator and list in the SUMMARY what to click (add a public cloud such as a real customer URL, manager vs USE view, sorting, widget).
</action>
<verify>
<automated>pnpm --filter @tessera/api test && pnpm --filter @tessera/web test && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && node -e 'const de=require("./apps/web/src/messages/de.json"),en=require("./apps/web/src/messages/en.json");const w=(o,p,r)=>{for(const[k,v]of Object.entries(o||{})){const q=p+"."+k;if(v&&typeof v==="object")w(v,q,r);else r[q]=v}return r};const pick=(m)=>({...w(m.nextcloudStatus,"nextcloudStatus",{}),...w(m.widgets&&m.widgets.nextcloudStatus,"widgets.nextcloudStatus",{})});const a=pick(de),b=pick(en);if(Object.keys(a).length<10||Object.keys(a).sort().join()!==Object.keys(b).sort().join()){console.error("key mismatch");process.exit(1)}for(const v of [...Object.values(a),...Object.values(b)])if(/mandant|tenant/i.test(String(v))){console.error("bad text",v);process.exit(1)}' && grep -q "Nextcloud" CHANGELOG.md && grep -q "### Nextcloud-Status" docs/anleitung-anwender.md && docker compose ps --status running --services | grep -qx api && docker compose ps --status running --services | grep -qx web && docker compose logs api 2>&1 | grep -q "Nextcloud-Status module seeded in registry"</automated>
</verify>
<done>Dashboard tile registered (shared types, module map, registry, icon, wrapper, page) and visible only with module access; widget shows counters and red/yellow clouds and links to the module; CHANGELOG, user and admin docs updated; full api + web suites, tsc and biome green; api and web rebuilt and running with the module seeded; commit on main, not pushed.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| browser → API (`/modules/nextcloud-status/*`) | untrusted caller; tenant/user/role only from the validated JWT |
| API → customer cloud (`<url>/status.php`) | outbound request to a manager-entered address, untrusted response |
| API → endoflife.date | outbound request to a public service, untrusted response |
| browser → logo URL host | the viewer's browser loads an external image |
| manager upload → DB → other users' browsers | uploaded bytes are served back to every module user |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-k67-01 | Information Disclosure (SSRF) | fetchNextcloudStatus | medium | accept | Internal targets allowed by design like Proxmox (L-03); limits: only GET `<url>/status.php`, no credentials, http/https, 3 redirects, 10 s, 64 KiB, whitelisted parsed fields only, errorDetail only own codes; write access requires Verwalten; documented in the code comment and admin docs |
| T-k67-02 | Tampering / Spoofing | logo upload + GET logo | high | mitigate | multer fileSize 1 MiB + service re-check; type from magic bytes (PNG/JPEG/GIF/WebP, no SVG); served with detected mime, nosniff, `default-src 'none'; sandbox`, private cache; logo-rules spec |
| T-k67-03 | Elevation of Privilege | controller write/check/logo routes | high | mitigate | `@ModuleManage('nextcloud-status')` on every write/check handler, no role decorator; controller spec + module-manage-handlers.spec metadata; verify gate greps for role decorators |
| T-k67-04 | Information Disclosure | cross-tenant rows | high | mitigate | forTenant on every request path, `where: { id, tenantId }` → 404, tenant_isolation_policy; system read limited to `select { id, tenantId }` in one allowlisted call site (rls-access-inventory) |
| T-k67-05 | Information Disclosure | external logo URL | low | mitigate | https only (DTO), `referrerPolicy="no-referrer"` on the img, API never fetches it (L-02) |
| T-k67-06 | Denial of Service | hourly job / reference fetch | medium | mitigate | concurrency 4, overlap guard, per-instance try/catch, 10 s timeouts, size caps; reference cache 12 h with 15 min failure backoff and shared in-flight request |
| T-k67-07 | Information Disclosure | list response | medium | mitigate | `PUBLIC_SELECT` without logo bytes; raw bodies never stored or returned; service spec asserts the select |
| T-k67-08 | Elevation of Privilege | route shadowing (`instances/check` vs `:id`) | medium | mitigate | static route declared before `:id` routes; declaration-order assertion in the controller spec |
| T-k67-09 | Tampering | stored URL | low | mitigate | normalizeCloudUrl rejects non-http(s), embedded credentials, overlong input; link rendered with `rel="noopener noreferrer"` |
| T-k67-SC | Tampering | npm/pip/cargo installs | low | accept | No new packages (undici, class-validator, multer, cron already present); nothing to verify |
</threat_model>
<verification>
- Task verify commands all pass; Task 3 runs the full api + web suites (includes rls-coverage, rls-access-inventory, umlaut-guard, module-layouts, widget enumerations).
- `prisma migrate status` up to date locally; drift check exit 0.
- `docker compose ps` shows api and web running after the rebuild; api log shows the seed line and the routes.
- Source coverage audit:
| Source item | Covered by |
|-------------|------------|
| GOAL: Nextcloud-Status module with traffic-light tiles | Tasks 1-3 |
| L-01 slug/category, Proxmox touchpoints end to end | Task 1 (API, migration, web route, registrations, layouts test), Task 3 (widget registration) |
| L-02 URL + Kundenname, logo upload (bytea, magic bytes, 1 MiB, auth route) or https URL | Task 1 (model, create), Task 2 (logo routes, form) |
| L-03 status.php fetch limits, stored result, SSRF comment, no raw bodies | Task 1 |
| L-04 endoflife.date cache 12 h, outage-tolerant, grey without data | Task 1 |
| L-05 traffic-light rules + newest version, pure function with date injection | Task 1 |
| L-06 tile content and reason texts | Task 1 |
| L-07 four sort options remembered per user | Task 2 |
| L-08 hourly job (onApplicationBootstrap), "Jetzt prüfen", per-tile refresh, system context | Task 1 (checkOne), Task 2 (checkAll, scheduler) |
| L-09 rights USE vs MANAGE/admin, useCanManageModule | Task 1 (create/checkOne), Task 2 (all write routes, UI gating) |
| L-10 dashboard widget counters + red/yellow list | Task 3 |
| L-11 German Sie + English, no tenant wording, tokens | Tasks 1-3 + node key check |
| L-12 tests, full suites, tsc, biome | Tasks 1-3 |
| L-13 CHANGELOG + docs | Task 3 |
| L-14 local migration, rebuild, no push | Task 1 (migrate), Task 3 (rebuild) |
</verification>
<success_criteria>
- `NextcloudInstance` exists with RLS policies; migration applied locally without drift.
- Rating, parser, fetch, release cache, service, controller, scheduler, logo rules, sort, page, form and widget tests pass; full api + web suites, both tsc runs and biome on touched files are green.
- USE users see rated tiles and can sort; managers and admins can additionally add/edit/delete clouds, manage logos and trigger checks; the API enforces this with ModuleManage.
- The hourly job is registered on every start, independent of existing rows.
- The dashboard tile appears in the catalog only with module access and links to the module.
- CHANGELOG and both guides describe the module; three commits on main, nothing pushed.
</success_criteria>
<output>
Create `.planning/quick/261002-k67-modul-nextcloud-status-mit-ampel-kacheln/261002-k67-SUMMARY.md` when done (not committed by the executor).
</output>