--- 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 /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'\\)" --- 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 `/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. @~/.claude/gsd-core/workflows/execute-plan.md @~/.claude/gsd-core/templates/summary.md @.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/` with a `?v=` 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..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@: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) `. 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 Task 1: Tracer — a registered cloud is checked, rated and shown as a tile (DB → status.php → endoflife reference → rating → GET instances → module page) 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 - 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 '/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. **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 ``; 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` and `refresh(): Promise` 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 `` (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//logo?v=` 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 `` (mock `@/lib/nextcloud-status-api` and next-intl like the proxmox page tests). Biome-lint the touched files (`pnpm exec biome lint ` 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. 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 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. Task 2: Managers maintain clouds (edit, delete, logo), trigger checks, hourly job runs; everyone can sort the tiles 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 - 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:', 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. **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 `` 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:`, everything in try/catch, unknown values fall back to 'name'. Page: sort `