docs(quick-261002-kxc): Nextcloud-Status Benachrichtigung
Tessera CI/CD / Lint & Type Check (push) Successful in 58s
Tessera CI/CD / Tests (push) Successful in 2m19s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 23s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m44s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-02 15:37:43 +02:00
parent 6c4bff6f6c
commit 14674fced3
3 changed files with 463 additions and 1 deletions
@@ -0,0 +1,306 @@
---
phase: quick-261002-kxc
plan: 01
type: execute
wave: 1
depends_on: []
quick_id: 261002-kxc
description: "Nextcloud-Status: persönliche Benachrichtigung (Glocke je Kachel) bei Störung und Wiederherstellung, per E-Mail und in Tessera"
date: 2026-10-02
files_modified:
# Task 1 — tracer: DB -> Ausfall-Regeln -> Pruefung -> Anspruch -> Mail; Glocke API + Kachel
- apps/api/prisma/schema.prisma
- apps/api/prisma/migrations/20261002170000_nextcloud_alerts/migration.sql
- apps/api/src/nextcloud-status/nextcloud-alert-rules.ts
- apps/api/src/nextcloud-status/nextcloud-alert-rules.spec.ts
- apps/api/src/nextcloud-status/nextcloud-alert-mail.ts
- apps/api/src/nextcloud-status/nextcloud-alert-mail.spec.ts
- apps/api/src/nextcloud-status/nextcloud-alert.service.ts
- apps/api/src/nextcloud-status/nextcloud-alert.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/nextcloud-status.module.ts
- apps/api/src/mail/mail.service.ts
- apps/api/src/mail/mail.service.spec.ts
- apps/api/src/module-registry/module-manage-handlers.spec.ts
- docs/mandantentrennung-zugriffsklassifikation.md
- apps/web/src/lib/nextcloud-status-api.ts
- apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudTile.tsx
- apps/web/src/app/(portal)/modules/nextcloud-status/page.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
# Task 2 — Wiederholung nach 5 min, Adresswechsel, Hinweis auf der Kachel
- apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.ts
- apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.spec.ts
# Task 3 — In-App-Meldung, Anleitung, Changelog, Abschluss
- apps/web/src/components/nextcloud-status/nextcloud-alert-notifier.tsx
- apps/web/src/components/nextcloud-status/nextcloud-alert-notifier.test.tsx
- apps/web/src/components/layout/app-shell.tsx
- CHANGELOG.md
- docs/anleitung-anwender.md
- docs/anleitung-administration.md
autonomous: true
requirements: [QUICK-261002-kxc]
estimate:
tokens: 150000
raw_tokens: 150000
tasks: 3
confidence: low
must_haves:
truths:
- "Every user who can open Nextcloud-Status (grant Benutzen or Verwalten, or administrator) sees a bell on each cloud tile, can switch it on and off, and the state survives a reload; it is personal per user and per cloud (L-01)"
- "When a subscribed cloud turns red (unreachable, invalid answer, maintenance, database upgrade pending, support expired) every subscriber with current module access and an active account gets exactly one e-mail; while it stays red nothing more is sent; when it turns yellow or green again exactly one 'wieder in Ordnung' e-mail follows (L-02, L-05, L-06)"
- "A cloud that fails one check keeps showing its last known state with the hint 'Prüfung fehlgeschlagen, wird in wenigen Minuten wiederholt'; it turns red and triggers the notification only after a second failed check, and Tessera repeats the check about 5 minutes after the first failure instead of waiting for the next hour; maintenance, database upgrade and expired support count immediately (L-03)"
- "Two concurrent checks, several API instances or a restart never send the same notification twice — the transition is claimed on the cloud row before any mail is sent (L-02)"
- "Without SMTP setup, without an e-mail address, with a deactivated account or without module access the mail is skipped and only logged; a failing SMTP transport is tried at most three times (L-04, L-06)"
- "While Tessera is open (browser or desktop app) a subscriber also gets an on-screen notification for each transition — in the desktop app as a Windows notification — at most once per transition and client (L-04)"
- "No user-facing text (mail, tile, notification, docs, changelog) contains a word for tenant (L-10)"
artifacts:
- path: "apps/api/prisma/migrations/20261002170000_nextcloud_alerts/migration.sql"
provides: "NextcloudAlertSubscription table (RLS with user dimension, cascades) + alert/failure columns on NextcloudInstance"
contains: "NextcloudAlertSubscription"
- path: "apps/api/src/nextcloud-status/nextcloud-alert-rules.ts"
provides: "pure decideAlert + planStatusWrite + retry constants"
exports: ["decideAlert", "planStatusWrite", "FAILURES_FOR_RED", "RETRY_DELAY_MS"]
- path: "apps/api/src/nextcloud-status/nextcloud-alert-mail.ts"
provides: "pure German mail builder (subject + plain text)"
exports: ["buildNextcloudAlertMail"]
- path: "apps/api/src/nextcloud-status/nextcloud-alert.service.ts"
provides: "subscriptions, claim-before-send, recipient re-check, mail delivery with 3 attempts, recent alerts per user"
- path: "apps/web/src/components/nextcloud-status/nextcloud-alert-notifier.tsx"
provides: "global in-app notifier mounted in AppShell"
key_links:
- from: "NextcloudStatusService.checkInstance"
to: "planStatusWrite -> rateNextcloud -> NextcloudAlertService.evaluateAfterCheck"
via: "every check (hourly, retry, manual, create, URL change) runs the guard and the transition claim"
pattern: "evaluateAfterCheck\\("
- from: "NextcloudAlertService.evaluateAfterCheck"
to: "nextcloudInstance.updateMany where alertState = previous state"
via: "claim-before-send: only count === 1 notifies"
pattern: "alertState: prev"
- from: "NextcloudStatusSchedulerService retry job"
to: "NextcloudStatusService.loadAllInstancesForScheduler({ retryDueBefore })"
via: "same single forSystem call site, filtered to consecutiveFailures = 1"
pattern: "retryDueBefore"
- from: "apps/web/src/components/layout/app-shell.tsx"
to: "NextcloudAlertNotifier -> GET /modules/nextcloud-status/alerts -> showReminderNotification"
via: "global mount next to ReminderNotifier"
pattern: "<NextcloudAlertNotifier />"
---
<objective>
Extend the module `nextcloud-status` (quick 261002-k67) with personal notifications: a bell per tile, e-mail plus in-app notification when a subscribed cloud goes red and when it recovers, with a two-strike guard against flapping and a quick re-check after the first failure.
Locked decisions from the request (cited below as L-xx):
- L-01 Every user who can see the module (grant USE or MANAGE, or admin) gets a bell toggle "Benachrichtigen" on each tile, personal per user per cloud. New Prisma table user x instance, tenant RLS like the other tables, cascade on instance/user delete. Toggle endpoints need only module access (USE), not manage. Tile shows the bell state; the list endpoint returns `subscribed` per instance for the current user.
- L-02 Trigger: transition INTO red (unreachable, maintenance, needsDbUpgrade, EOL passed, invalid response) notifies subscribers once; staying red = no repeat; red back to yellow/green = one "wieder in Ordnung" notification. Last notified state stored on the instance so restarts/multiple polls never duplicate; claim-before-send with an `updateMany` guard like `reminder-mail.scheduler.ts`.
- L-03 Flapping guard: "unreachable" only counts as red after 2 consecutive failed checks (counter on the instance); the tile keeps showing the last good state plus a hint until the second failure; other red reasons (maintenance, EOL) count immediately; after a first failure the cloud is re-checked after about 5 minutes.
- L-04 Channels: e-mail via the existing MailService/SMTP config exactly like reminder mails (skip silently + log if SMTP missing, user has no e-mail or is inactive; max 3 attempts) plus an in-app notification while Tessera is open, reusing the reminder mechanism (`reminder-notifier.tsx`, `reminder-notify.ts`; desktop app = Windows notification) with a small "recent alerts" endpoint.
- L-05 Mail text German, plain and short: subject "Nextcloud <Kundenname>: nicht erreichbar" / "Nextcloud <Kundenname>: wieder in Ordnung", body with reason, URL, time and link to the module. Reminder mails are German-only (`MailService.sendReminderEmail`), so these mails are German-only too.
- L-06 Only users who still have module access at send time (grant re-checked) and only active users are notified.
- L-07 Tests: transition logic as pure function (green->red, red->red, red->green, unreachable once vs twice, maintenance immediate), claim/no-duplicate, subscription endpoints + guard metadata, web bell toggle + notifier; full api + web suites, tsc, biome on touched files.
- L-08 CHANGELOG (Unveröffentlicht, German, user-facing) + docs update.
- L-09 Local migration via db container IP + `prisma migrate deploy`; rebuild `docker compose up -d --build api web`.
- L-10 No word for tenant in user texts. Do not push. SUMMARY documents how to force a red transition locally and whether local SMTP is configured (it is: one `SmtpConfig` row with a host exists in the local db).
Claude's discretion, decided here (cited as D-Kx):
- D-K1 The two-strike guard applies to every failed fetch (`reachable === false`, all error kinds incl. `not-nextcloud`): each comes from one HTTP call and can be transient (a proxy error page during a restart is an "invalid answer"). Maintenance and DB upgrade come from a successful answer, EOL from the date — both immediate.
- D-K2 First failure writes ONLY `consecutiveFailures = 1` and `firstFailureAt`; all status fields and `lastCheckedAt` stay untouched, so the rating (computed from stored fields) keeps the last good state. A never-checked cloud therefore stays grey "Noch nicht geprüft" plus the hint. The second failure writes the failure fields as today.
- D-K3 Quick retry = a second cron job `nextcloud-status-retry` every minute that checks only clouds with exactly one failure whose `firstFailureAt` is at least 5 minutes old. It reuses the ONE existing `forSystem` call site (`loadAllInstancesForScheduler` gets an optional filter) — no new system read, no new policy. State lives on the row, so it survives restarts.
- D-K4 Transition state on the instance: `alertState` ('ok' | 'red', default 'ok'), `alertReason`, `alertChangedAt`. Grey ("unknown") changes nothing. Existing rows start as 'ok'.
- D-K5 Mail delivery runs in the background after a won claim (never blocks "Jetzt prüfen"); per recipient up to 3 attempts, 60 s apart, in-process. A restart between attempts drops the remaining ones — accepted: a late status mail after a restart has little value, and tile plus in-app notification show the state anyway. Skips (no SMTP / no address / inactive / no access) are logged and never retried.
- D-K6 Subject per red reason: unreachable uses the locked wording "nicht erreichbar"; the other red reasons get their own short wording ("keine gültige Antwort", "im Wartungsmodus", "Datenbank-Aktualisierung ausstehend", "Support abgelaufen") so a subject is never factually wrong; recovery uses the locked "wieder in Ordnung".
- D-K7 In-app: `GET modules/nextcloud-status/alerts` returns, for the caller's subscribed clouds, the latest transition of the last 24 h that happened after the caller subscribed. The client dedupes per (cloud, changedAt) with the existing reminder helpers and only polls when the user has module access.
- D-K8 Changing a cloud's address resets the status fields and the failure counter but KEEPS `alertState` — fixing a broken address therefore sends "wieder in Ordnung" to subscribers.
- D-K9 Subscription RLS with user dimension like "Reminder" (`current_user_id() IS NULL OR "userId" = current_user_id()`), no `system_read_policy` (never read in system context).
- D-K10 List responses (`GET instances`, `POST instances/check`) carry `subscribed`; single-cloud responses do not — the page keeps the tile's bell state when it swaps in a single checked tile. Switching a bell on for the first time asks the browser for notification permission once (`requestBrowserPermissionOnce`).
Purpose: subscribers learn about an outage within minutes instead of noticing it on the next visit, without false alarms from a single hiccup.
Output: one migration, alert rules/mail/service in `apps/api/src/nextcloud-status/`, bell + hint on the tile, global notifier, docs and changelog.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@./CLAUDE.md
@.planning/quick/261002-k67-modul-nextcloud-status-mit-ampel-kacheln/261002-k67-SUMMARY.md
@apps/api/src/nextcloud-status/nextcloud-status.service.ts
@apps/api/src/nextcloud-status/nextcloud-status.controller.ts
@apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.ts
@apps/api/src/nextcloud-status/nextcloud-rating.ts
@apps/api/src/reminders/reminder-mail.scheduler.ts
@apps/api/src/module-registry/module-access.service.ts
@apps/web/src/lib/reminder-notify.ts
@apps/web/src/components/reminders/reminder-notifier.tsx
@apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudTile.tsx
Facts gathered during planning (no need to re-discover):
- `rateNextcloud(status, reference, now)` computes the rating from the STORED fields; `NextcloudStatusService.toView` calls it at read time. `checkInstance(tenantId, id)` is the single write path for every check (hourly tick, "Jetzt prüfen", per-tile check, create, URL change).
- `fetchNextcloudStatus` returns `reachable: false` with `errorKind` in timeout | network | tls | http-status | not-nextcloud | too-large | redirect; `errorDetail` is a short code like `HTTP 502` or `ECONNREFUSED`.
- `ModuleAccessService.getModuleAccessLevels(tenantId, userId, role)` (exported by `ModuleRegistryModule`) is the single source for access incl. admin short-circuit; `ModuleRegistryService.findBySlug('nextcloud-status')` gives the module id. `MailModule` exports `MailService`, `SettingsModule` exports `SettingsService.getSmtpConfig(tenantId)` (null = not set up). `MailService` keeps `appUrl` from `TESSERA_APP_URL`; reminder mails are German-only, time zone Europe/Berlin.
- Controllers get the user via `@CurrentUser() user: AuthUser` (see `reminders.controller.ts`), tenant via `requireTenantId(req)`.
- `forTenant(prisma, tenantId, userId?)` sets the user context; every call must use the assignment form `const tenantPrisma = forTenant(...)` (checked by `rls-access-inventory.spec.ts`), `forSystem` only `const systemPrisma = forSystem(...)`. `FORSYSTEM_ALLOWED_CALL_SITES` allows exactly 1 call in `nextcloud-status.service.ts` — keep it at 1. Selects stay scalar (no relation keys) so no new relation pairs appear.
- `docs/mandantentrennung-zugriffsklassifikation.md` keeps a Bereichszeile `nextcloud-status` (currently 0/14/1), a Summenzeile (61/264/8), pair counts (93) and the Fundstellentabelle; k67 shows how each task updated them with the Gate-Schleife. Every new (file, model) pair needs a row.
- Web: `reminder-notify.ts` exports `claimNotification(key, nowMs)`, `withNotifyLock`, `showReminderNotification({title, body, tag})` (Tauri plugin in the desktop app, Web Notification in the browser), `requestBrowserPermissionOnce`, `CATCH_UP_WINDOW_MS`. `ReminderNotifier` is mounted in `apps/web/src/components/layout/app-shell.tsx`. Access check pattern: `GET /modules/active` (see `use-module-capability.ts`).
- Local SMTP is configured (one `SmtpConfig` row with host). Containers `tessera-ctl-db-1`, `tessera-ctl-api-1`, `tessera-ctl-web-1` run. Latest migration: `20261002150000_nextcloud_status`.
</context>
<tasks>
<task type="tracer" tdd="true">
<name>Task 1 (Tracer): Glocke einschalten -> Cloud fällt zweimal aus -> genau eine Mail an den Abonnenten</name>
<files>apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20261002170000_nextcloud_alerts/migration.sql, apps/api/src/nextcloud-status/nextcloud-alert-rules.ts, apps/api/src/nextcloud-status/nextcloud-alert-rules.spec.ts, apps/api/src/nextcloud-status/nextcloud-alert-mail.ts, apps/api/src/nextcloud-status/nextcloud-alert-mail.spec.ts, apps/api/src/nextcloud-status/nextcloud-alert.service.ts, apps/api/src/nextcloud-status/nextcloud-alert.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/nextcloud-status.module.ts, apps/api/src/mail/mail.service.ts, apps/api/src/mail/mail.service.spec.ts, apps/api/src/module-registry/module-manage-handlers.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md, apps/web/src/lib/nextcloud-status-api.ts, apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudTile.tsx, apps/web/src/app/(portal)/modules/nextcloud-status/page.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</files>
<precondition>`docker ps` lists `tessera-ctl-db-1` as running (needed for the local migration).</precondition>
<behavior>
- decideAlert('ok', red) -> 'down'; ('red', red) -> null; ('red', green) -> 'up'; ('red', yellow) -> 'up'; ('red', unknown) -> null; ('ok', green) -> null; ('ok', unknown) -> null
- planStatusWrite(prevFailures 0, failed result) -> outcome 'pending', data only consecutiveFailures 1 + firstFailureAt now (no status field, no lastCheckedAt); prevFailures 1 + failed -> 'confirmed' with reachable false, errorKind, errorDetail, lastCheckedAt and consecutiveFailures 2; any success -> 'ok' with all status fields, consecutiveFailures 0, firstFailureAt null; success with maintenance true -> 'ok' (rating red immediately)
- Combined (pure, with rateNextcloud): green cloud + one failure -> rating stays green -> no alert; + second failure -> red -> 'down'; green + maintenance answer -> 'down' at once; red + green answer -> 'up'
- buildNextcloudAlertMail down/unreachable -> subject exactly "Nextcloud <Kundenname>: nicht erreichbar"; up -> "Nextcloud <Kundenname>: wieder in Ordnung"; body contains reason line, URL, time (Europe/Berlin) and "<appUrl>/modules/nextcloud-status"; CR/LF in Kundenname never reaches the subject; no word for tenant in any output
- evaluateAfterCheck: claim updateMany count 1 -> mails to eligible subscribers; count 0 -> no mail (second concurrent check / second API instance); red -> red -> no claim, no mail
- Recipients: inactive user, user without e-mail, user without module access (re-checked via getModuleAccessLevels), missing SMTP -> skipped + logged, no send; send returning false twice then true -> exactly 3 calls; false three times -> 3 calls, then stop
- Subscription endpoints: subscribe is idempotent, unknown/foreign cloud -> 404, unsubscribe removes only the caller's row; list returns subscribed true only for the caller's subscriptions; handlers carry no manage metadata and no role metadata
- Web: bell visible for a USE-only user, aria-pressed reflects subscribed, click calls subscribe/unsubscribe and flips state, failure rolls back and shows the error text; single-tile check keeps the bell state
</behavior>
<action>
**Schema + migration (L-01, L-02, L-03, D-K4, D-K9).** In `apps/api/prisma/schema.prisma` extend `NextcloudInstance` with `consecutiveFailures Int @default(0)`, `firstFailureAt DateTime?`, `alertState String @default("ok")` (comment: 'ok' | 'red', last notified state), `alertReason String?`, `alertChangedAt DateTime?`, and the back-relation `subscriptions NextcloudAlertSubscription[]`; add `nextcloudAlertSubscriptions NextcloudAlertSubscription[]` to `User`; add `model NextcloudAlertSubscription` (German comment, quick-261002-kxc) with `id String @id @default(uuid())`, `tenantId String`, `userId String` + relation to User `onDelete: Cascade`, `instanceId String` + relation to NextcloudInstance `onDelete: Cascade`, `createdAt DateTime @default(now())`, `@@unique([instanceId, userId])`, `@@index([tenantId, userId])`. Hand-write `apps/api/prisma/migrations/20261002170000_nextcloud_alerts/migration.sql` in the style of `20261002150000_nextcloud_status` and `20260929140000_reminder`: German header (purpose; subscription is personal data, hence tenant_isolation_policy WITH user dimension exactly like "Reminder"; no system_read_policy because the table is never read in system context; the new instance columns are covered by the existing policies of "NextcloudInstance"; rights via ALTER DEFAULT PRIVILEGES; switch-is-off note), ALTER TABLE for the five columns (alertState NOT NULL DEFAULT 'ok', consecutiveFailures NOT NULL DEFAULT 0), CREATE TABLE, unique index, index, both foreign keys ON DELETE CASCADE ON UPDATE CASCADE, ENABLE + FORCE ROW LEVEL SECURITY, the policy. Run `pnpm --filter @tessera/api exec prisma generate`; apply locally with the container IP (`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`), 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 (L-09).
**Pure rules, TDD first (L-02, L-03, D-K1, D-K2).** `nextcloud-alert-rules.ts`, no Nest, no Prisma, date passed in: `FAILURES_FOR_RED = 2`, `RETRY_DELAY_MS = 5 * 60 * 1000`, type `AlertState = 'ok' | 'red'`, `decideAlert(prev: AlertState, level: RatingLevel): 'down' | 'up' | null` (grey changes nothing), and `planStatusWrite(prevFailures: number, result: NextcloudCheckResult, now: Date): { outcome: 'ok' | 'pending' | 'confirmed'; data: Record<string, unknown> }` per D-K2 (failure = `result.reachable === false`, any errorKind, D-K1). German header comment explaining the two-strike rule and why a first failure leaves the stored state alone. Write the spec with every case from `<behavior>` (incl. the combined cases that run `rateNextcloud` on the resulting stored fields) BEFORE the implementation, see it fail, then implement.
**Mail builder (L-05, D-K6).** `nextcloud-alert-mail.ts`, pure: `buildNextcloudAlertMail(input, appUrl): { subject: string; text: string }` with input `{ kind: 'down' | 'up'; customerName; baseUrl; rating: NextcloudRating; errorKind; errorDetail; at: Date }`. Subject `Nextcloud <Kundenname>: <wording>` (down wording per reason per D-K6, up "wieder in Ordnung"), CR/LF collapsed to a space, max 150 characters (same as `sendReminderEmail`). Plain-text body, short, Sie-form: "Guten Tag,", one sentence (down: the cloud "<Kundenname>" has a problem since <time>; up: is back in order), "Grund:" line (down: German reason text — "Nicht erreichbar" with errorDetail or a German word for the errorKind in brackets, "Keine gültige Nextcloud-Antwort", "Wartungsmodus eingeschaltet", "Datenbank-Aktualisierung ausstehend", "Support abgelaufen seit <TT.MM.JJJJ>"; up: "Aktueller Stand:" with "Aktuell" / "Update auf <x> verfügbar" / "Support endet am <TT.MM.JJJJ>"), "Adresse: <baseUrl>", "Zeitpunkt: <de-DE, Europe/Berlin, dateStyle full, timeStyle short> Uhr", "Zum Modul: <appUrl>/modules/nextcloud-status", closing line that the mail comes because "Benachrichtigen" is switched on for this cloud and can be switched off with the bell on the tile. Spec covers every subject wording, the locked two subjects verbatim, header-injection stripping, link, and a case-insensitive check that neither subject nor text contains a word for tenant (German or English). In `MailService` add `sendNextcloudAlertEmail(tenantId, to, input)` next to `sendReminderEmail`: builds via `buildNextcloudAlertMail(input, this.appUrl)`, sends through `this.deliver(tenantId, ..., 'NextcloudAlert')`, returns true/false and logs on failure exactly like `sendReminderEmail`; add spec cases mirroring the reminder ones.
**Alert service (L-01, L-02, L-04, L-06, D-K4, D-K5).** `nextcloud-alert.service.ts` (`@Injectable`, deps PrismaService, MailService, SettingsService, ModuleAccessService, ModuleRegistryService). Methods: `subscribe(tenantId, userId, instanceId)` (instance must exist with `{ id, tenantId }` else NotFoundException 'Cloud nicht gefunden'; upsert on the unique pair with `forTenant(prisma, tenantId, userId)`; returns `{ subscribed: true }`), `unsubscribe(...)` (deleteMany where tenantId, userId, instanceId; returns `{ subscribed: false }`), `subscribedInstanceIds(tenantId, userId): Promise<Set<string>>`, `evaluateAfterCheck(tenantId, row, rating, now)` where row carries id, customerName, baseUrl, errorKind, errorDetail, alertState: compute `decideAlert`; null -> return `{ kind: null, delivery: null }`; otherwise claim with `nextcloudInstance.updateMany({ where: { id, tenantId, alertState: prev }, data: { alertState: next, alertReason: down ? rating.reason : null, alertChangedAt: now } })` — only `count === 1` continues (header comment: why the claim stands before sending, same reasoning as `ReminderMailScheduler`). Then start `notifySubscribers` WITHOUT awaiting it inside the check path and return `{ kind, delivery }` (the promise, `.catch` logs) so tests can await it. `notifySubscribers`: subscriptions of the instance (tenant-bound, no user filter), users `where { tenantId, id in, isActive: true }` with scalar select email + role, module id via `findBySlug('nextcloud-status')`, per user `getModuleAccessLevels(tenantId, user.id, user.role)` must contain the module (L-06), SMTP via `getSmtpConfig(tenantId)`; every skip logs one German line with the reason (Benutzer deaktiviert / keine E-Mail-Adresse / kein Modulzugriff / kein E-Mail-Versand eingerichtet) and is never retried; eligible recipients get `sendNextcloudAlertEmail` with up to 3 attempts, `ALERT_MAIL_RETRY_MS = 60_000` apart via an overridable `sleep` member (D-K5, comment states that a restart drops pending attempts and why that is accepted). Spec: claim won/lost, red->red no claim, all skip reasons, 1-3 attempts, access revoked, inactive user.
**Wire into the existing service and controller.** `NextcloudStatusService` gets `NextcloudAlertService` injected. `checkInstance`: load `{ id, baseUrl, consecutiveFailures }`, fetch, `planStatusWrite`, update with that data selecting `PUBLIC_SELECT` plus `alertState`, build the view, then `await this.alerts.evaluateAfterCheck(...)` (awaits only the claim) and return the view. `listForTenant(tenantId, userId)` and `checkAllForTenant(tenantId, userId)` add `subscribed: boolean` per instance from `subscribedInstanceIds` (D-K10); `NextcloudInstanceView` gets an optional `subscribed`. Update the existing service spec (constructor, two-failure path, subscribed flag). Controller: `list` and `checkAll` pass `user.id` via `@CurrentUser()`; new `POST instances/:id/subscription` and `DELETE instances/:id/subscription` with only the class-level `@UseModule` — no manage decorator, no role decorator (L-01). Module imports `MailModule` and `SettingsModule` and provides `NextcloudAlertService`. Extend `module-manage-handlers.spec.ts` so `subscribe`/`unsubscribe` are asserted to stay on Benutzen level next to `list`/`logo`; controller spec covers the two routes (user id from `@CurrentUser`, never from body).
**RLS bookkeeping.** Run `pnpm --filter @tessera/api exec vitest run rls-coverage rls-access-inventory`; add the Fundstellentabelle rows for the new (file, model) pairs (e.g. `nextcloud-alert.service.ts` with `nextcloudInstance`, `nextcloudAlertSubscription`, `user`), update the Bereichszeile `nextcloud-status`, the Summenzeile and the Paarzählung in `docs/mandantentrennung-zugriffsklassifikation.md`, counted with the Gate-Schleife exactly like the k67 entries (measured, not copied). `FORSYSTEM_ALLOWED_CALL_SITES` stays unchanged.
**Web bell (L-01, D-K10).** `nextcloud-status-api.ts`: optional `subscribed?: boolean` on `NextcloudInstance` (missing = false, keeps existing fixtures valid), `subscribe(id)` (POST `${BASE}/${id}/subscription`) and `unsubscribe(id)` (DELETE) with `readErrorMessage`. `CloudTile`: new props `subscribed`, `onToggleSubscription`, `toggling`; a bell icon button shown to EVERY user (outside the `canManage` block, left of the manager buttons), `aria-pressed`, aria-label "Benachrichtigen", title from `bell.titleOn` / `bell.titleOff`, outlined bell when off, filled bell in accent color when on, disabled while toggling. `page.tsx`: optimistic toggle, call subscribe/unsubscribe, on error roll back and show `bell.error` as a small line above the grid; on switching on call `requestBrowserPermissionOnce()` from `@/lib/reminder-notify`; `handleCheckOne` keeps the previous `subscribed` when swapping in the checked tile. Texts in `nextcloudStatus.bell` (`label`, `titleOn`, `titleOff`, `error`) in de.json (real umlauts, Sie-form) and en.json with identical keys. Page test: bell visible for a USE-only user, toggle on/off calls the right function and flips aria-pressed, rollback on error, single check keeps the state.
Biome-lint touched files (`pnpm exec biome lint <files>` from repo root, `biome check --write` only on new files). Commit `feat(nextcloud-status): Benachrichtigung abonnieren und Mail bei Störung` (attribution line). Do not push.
</action>
<verify>
<automated>pnpm --filter @tessera/api exec vitest run src/nextcloud-status src/mail 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 && DATABASE_URL="postgresql://tessera:tessera_dev@$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1):5432/tessera" pnpm --filter @tessera/api exec prisma migrate status</automated>
</verify>
<done>Migration applied locally and drift-free; pure rules, mail builder, alert service, status service, controller, mail service and manage-handler specs green; a subscribed user's cloud failing twice produces exactly one claimed transition and one mail per eligible recipient; the bell works for USE-level users in the web test; RLS inventory specs green with updated doc; commit on main, not pushed.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Wiederholung nach 5 Minuten, Adresswechsel und Hinweis „Prüfung fehlgeschlagen“ auf der Kachel</name>
<files>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.service.ts, apps/api/src/nextcloud-status/nextcloud-status.service.spec.ts, apps/api/src/nextcloud-status/nextcloud-alert.service.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md, apps/web/src/lib/nextcloud-status-api.ts, 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/messages/de.json, apps/web/src/messages/en.json</files>
<behavior>
- retryTick loads only clouds with consecutiveFailures 1 and firstFailureAt at least RETRY_DELAY_MS old (filter passed to loadAllInstancesForScheduler), checks each tenant-bound, max 4 at a time, skips while a previous retry run is active, never throws
- onApplicationBootstrap registers both jobs (nextcloud-status-poll hourly, nextcloud-status-retry every minute) without database access
- loadAllInstancesForScheduler() without filter keeps today's query; with { retryDueBefore } adds where consecutiveFailures 1 and firstFailureAt lte retryDueBefore; select stays { id, tenantId }
- updateInstance with a new address resets reachable, maintenance, needsDbUpgrade, versionString, edition, productName, errorKind, errorDetail, lastCheckedAt, consecutiveFailures, firstFailureAt and does NOT touch alertState; a red cloud whose corrected address answers green yields 'up'
- view status.pendingRetry is true exactly when consecutiveFailures is 1; tile shows the hint then and keeps pill, version and last check from the stored state
</behavior>
<action>
**Retry job (L-03, D-K3).** In `nextcloud-status-scheduler.service.ts` export `NEXTCLOUD_RETRY_JOB_NAME = 'nextcloud-status-retry'` and `NEXTCLOUD_RETRY_CRON = '* * * * *'`; register it in the same `onApplicationBootstrap` inside the existing try/catch (same `CronJobClass` workaround, no database access at registration, log line `Nextcloud-Status retry job registered: * * * * *`). `retryTick(now = new Date())` with its own overlap flag: `loadAllInstancesForScheduler({ retryDueBefore: new Date(now - RETRY_DELAY_MS) })`, then `checkInstance(tenantId, id)` per row with `runWithConcurrency(..., CHECK_CONCURRENCY, ...)`, errors logged per cloud. Extend the header comment: why a second job instead of in-memory timers (survives restarts, state on the row) and why it reuses the one system read. In `NextcloudStatusService.loadAllInstancesForScheduler(filter?: { retryDueBefore: Date })` add the optional where (consecutiveFailures 1, firstFailureAt lte) to the SAME `systemPrisma.nextcloudInstance.findMany` call — still exactly one `forSystem` call in the file. Scheduler spec: both jobs registered, retry filter passed, overlap guard, error isolation.
**Address change (D-K8).** In `updateInstance`, when the normalized address changed, write the reset of the status fields, `consecutiveFailures: 0` and `firstFailureAt: null` together with the new address (alertState untouched), then call `checkInstance` as today. Service spec: reset written, alertState not in the data; a cloud with alertState 'red' whose new address answers green triggers `evaluateAfterCheck` with an 'up' decision (alert service spec: 'up' mail subject "wieder in Ordnung" and "Aktueller Stand" line).
**Hint on the tile (L-03, D-K2).** Add `consecutiveFailures` to `PUBLIC_SELECT`/`PublicRow` and `status.pendingRetry: boolean` (`consecutiveFailures === 1`) to the view; the rating input stays unchanged. Web: optional `pendingRetry?: boolean` in `NextcloudInstanceStatus`; `CloudTile` shows, when true, a small line with a warning-colored dot and the text `card.pendingRetry` ("Prüfung fehlgeschlagen, wird in wenigen Minuten wiederholt") between the pill row and the last-check line, `data-testid="pending-retry"`; pill, version and last check stay as stored. en.json gets the same key. Page test: hint shown with pendingRetry, not shown without, pill keeps the stored green level.
Re-run the RLS specs and adjust the doc only if the Gate-Schleife counts changed. Biome-lint touched files, commit `feat(nextcloud-status): erneute Prüfung nach Ausfall und Hinweis auf der Kachel` (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 "$(grep -v '^\s*//' apps/api/src/nextcloud-status/nextcloud-status.service.ts | grep -c 'forSystem(this.prisma)')" = "1"</automated>
</verify>
<done>Retry job registered and tested; first failure leaves the stored state and shows the hint, second failure (manual, retry or hourly) turns the cloud red; address change resets the check state but keeps the notification state; still exactly one system read in the service; specs and both tsc runs green; commit on main, not pushed.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: In-App-Meldung (Desktop: Windows-Benachrichtigung), Anleitung, Changelog, Abschluss und Neubau</name>
<files>apps/api/src/nextcloud-status/nextcloud-alert.service.ts, apps/api/src/nextcloud-status/nextcloud-alert.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/module-registry/module-manage-handlers.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md, apps/web/src/lib/nextcloud-status-api.ts, apps/web/src/components/nextcloud-status/nextcloud-alert-notifier.tsx, apps/web/src/components/nextcloud-status/nextcloud-alert-notifier.test.tsx, apps/web/src/components/layout/app-shell.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, CHANGELOG.md, docs/anleitung-anwender.md, docs/anleitung-administration.md</files>
<behavior>
- listRecentAlerts(tenantId, userId, now) returns only the caller's subscribed clouds with alertChangedAt within 24 h AND not before the caller's subscription createdAt; kind 'down' for alertState red (with reason), 'up' for ok; other users' subscriptions never appear
- GET alerts stays on Benutzen level (no manage, no role metadata)
- Notifier: without module access (slug missing in /modules/active) it never calls the alerts endpoint; with access it shows one notification per (instanceId, changedAt), never twice across polls; title for down/unreachable is "Nextcloud <Name>: nicht erreichbar", for up "Nextcloud <Name>: wieder in Ordnung", body is the address; 401/403 pauses polling until focus
</behavior>
<action>
**Recent alerts endpoint (L-04, D-K7).** `NextcloudAlertService.listRecentAlerts(tenantId, userId, now)`: subscriptions of the caller (`forTenant(prisma, tenantId, userId)`, scalar select instanceId + createdAt), then instances `where { tenantId, id in, alertChangedAt gte now - 24 h }` with scalar select id, customerName, baseUrl, alertState, alertReason, alertChangedAt; drop entries whose alertChangedAt is before the subscription's createdAt; return `{ alerts: [{ instanceId, customerName, baseUrl, kind, reason, changedAt }] }` sorted by changedAt. Controller `GET alerts` (path `modules/nextcloud-status/alerts`, user from `@CurrentUser()`), only class-level `@UseModule`. Specs: service filters, controller route, `module-manage-handlers.spec.ts` asserts Benutzen level for `alerts`. Update the RLS doc rows/counts with the Gate-Schleife and re-run the RLS specs.
**Global notifier (L-04).** `nextcloud-status-api.ts`: `NextcloudAlert` type and `listAlerts()` throwing an error object that carries the HTTP status (pattern `ReminderRequestError`). New `apps/web/src/components/nextcloud-status/nextcloud-alert-notifier.tsx`, modeled on `ReminderNotifier` (renders nothing, translation via refs): on mount and on focus/visibility resume it checks `GET /modules/active` for slug `nextcloud-status` (with `credentials: 'include'`, `cache: 'no-store'`); only with access it loads alerts immediately and every 60 s; for each alert under `withNotifyLock` it calls `claimNotification('nextcloud-alert|' + instanceId + '|' + changedAt, Date.now())` and on first claim `showReminderNotification({ title, body: baseUrl, tag })` — reuse of these helpers is deliberate (same Tauri path = Windows notification in the desktop app, same per-client memory; say so in the header comment). Titles from `nextcloudStatus.notify` in de.json/en.json: `up` and `down.unreachable`, `down.invalidResponse`, `down.maintenance`, `down.needsDbUpgrade`, `down.eolPassed`, each with `{name}`, German wording identical to the mail subjects (D-K6). 401/403 sets a paused flag until the next focus. Mount `<NextcloudAlertNotifier />` in `app-shell.tsx` right after `<ReminderNotifier />` with a short German comment. Notifier test modeled on `reminder-notifier.test.tsx`: no access -> no alerts call; access -> one notification per alert, none on the next poll, correct titles; 403 pauses.
**Docs + changelog (L-08, L-10).** CHANGELOG under "## Unveröffentlicht" / "### Neu": one user-facing German bullet — bell on each Nextcloud-Status tile, personal; mail and on-screen notification (desktop app: Windows notification) when the cloud fails and when it is back in order; one message per change; a single failed check does not alert, Tessera re-checks after about five minutes; mails need the e-mail setup. `docs/anleitung-anwender.md` section Nextcloud-Status: new paragraph "**Benachrichtigen:**" (who sees the bell, what triggers a message, two failed checks for "nicht erreichbar", immediate for maintenance/DB upgrade/support expired, "wieder in Ordnung", hint text on the tile, browser asks once for permission, mail only with e-mail setup and an address in the profile). `docs/anleitung-administration.md` section "Nextcloud-Status: Clouds eintragen": bullet on notifications (SMTP from section 6 required, recipients re-checked at send time, at most three attempts, changing the address sends "wieder in Ordnung" if it was red) and extend the "Rhythmus" bullet with the 5-minute re-check. No word for tenant anywhere in these texts.
**Final gates (L-07, L-09).** Full `pnpm --filter @tessera/api test` and `pnpm --filter @tessera/web test` (if an AppShell-rendering test breaks, mock the new notifier the way `ReminderNotifier` is mocked), both tsc, biome lint on all touched files of the three tasks. Rebuild `docker compose up -d --build api web`, wait for api healthy, check `docker compose logs api` for "Nextcloud-Status module seeded in registry", "Nextcloud-Status retry job registered" and the mapped routes `/modules/nextcloud-status/instances/:id/subscription` and `/modules/nextcloud-status/alerts`, no migration errors. Commit `feat(nextcloud-status): Meldung in Tessera, Anleitung und Changelog` (attribution line). Do not push.
**SUMMARY for the orchestrator's browser check (L-10):** list the click path to force both transitions locally — (1) "Cloud hinzufügen" with an unreachable address such as `https://127.0.0.1:9` (tile grey "Noch nicht geprüft" + hint), (2) switch the bell on (browser asks once for permission), (3) "Jetzt prüfen" or the tile's check button once more -> red, mail + on-screen notification "nicht erreichbar", (4) edit the address to a reachable public Nextcloud -> "wieder in Ordnung". State that local SMTP is configured (SmtpConfig row present) and name which local account address would receive the mail (look it up, do not change it); note that a USE-only user also sees the bell.
</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)}if(!a["nextcloudStatus.notify.up"]||!a["nextcloudStatus.bell.label"]||!a["nextcloudStatus.card.pendingRetry"]){console.error("missing keys");process.exit(1)}' && grep -q "Benachrichtig" CHANGELOG.md && grep -q "Benachrichtigen:" docs/anleitung-anwender.md && grep -q "NextcloudAlertNotifier" apps/web/src/components/layout/app-shell.tsx && 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 retry job registered"</automated>
</verify>
<done>Subscribers get an on-screen notification (desktop: Windows notification) once per transition while Tessera is open; users without access never poll; CHANGELOG and both guides describe the feature without a word for tenant; full api + web suites, tsc and biome green; api and web rebuilt and running with the retry job and new routes; SUMMARY contains the local test path and SMTP status; commit on main, not pushed.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| browser -> API (subscription, alerts) | user-controlled instance id; user identity must come from the JWT |
| API -> SMTP / recipient mailbox | Kundenname (manager-entered) flows into subject and body |
| background check -> notification fan-out | concurrent checks, several API instances, restarts |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-kxc-01 | Spoofing / Elevation | POST/DELETE instances/:id/subscription | high | mitigate | userId only from `@CurrentUser()`, tenantId only from `requireTenantId(req)`; instance must match `{ id, tenantId }` (404 otherwise); `forTenant(prisma, tenantId, userId)` + RLS user dimension; controller spec asserts no body field is used |
| T-kxc-02 | Information disclosure | GET alerts | medium | mitigate | query limited to the caller's own subscriptions (userId from JWT, user-bound client), scalar selects (name, address, state, reason, time — all already visible on the module page), class-level `@UseModule` so only users with access get any answer |
| T-kxc-03 | Information disclosure | mail fan-out | high | mitigate | at send time re-check `isActive`, e-mail present and `getModuleAccessLevels` contains the module (L-06); spec covers revoked access and deactivated user |
| T-kxc-04 | Repudiation / Tampering (duplicate sends) | evaluateAfterCheck | medium | mitigate | claim-before-send `updateMany where alertState = previous`; only `count === 1` notifies; state on the row survives restarts; spec covers the lost claim |
| T-kxc-05 | Tampering (header injection) | buildNextcloudAlertMail subject | medium | mitigate | CR/LF collapsed, 150-char cap (same as reminder subject); spec with a Kundenname containing a line break |
| T-kxc-06 | Denial of service (mail flood) | flapping cloud | medium | mitigate | two-strike rule, one mail per transition, grey changes nothing, retry job only touches clouds with exactly one failure, concurrency 4, overlap guards |
| T-kxc-07 | Denial of service (blocked request) | "Jetzt prüfen" with slow SMTP | low | mitigate | delivery runs in the background after the claim; check responses never await SMTP |
| T-kxc-08 | Elevation | new handlers | medium | mitigate | no manage and no role decorator on subscription/alerts handlers — asserted in `module-manage-handlers.spec.ts`; module guard still enforces access |
| T-kxc-SC | Tampering | npm/pip/cargo installs | low | accept | no package installs in this plan; only existing dependencies are used |
</threat_model>
<verification>
- Pure rules cover every transition case of L-07 (green->red, red->red, red->green/yellow, grey, one vs two failures, maintenance immediate).
- Claim-before-send prevents duplicates (spec with lost claim); recipients re-checked for access, active state, address, SMTP; at most 3 attempts.
- Retry job checks a cloud with one failure after 5 minutes; tile hint shown during that time.
- Bell visible and working for USE-level users; list carries `subscribed`; notifier fires once per transition and only with access.
- `prisma migrate status` up to date locally, drift check exit 0; RLS specs green with updated classification doc; still one `forSystem` call in the service.
- Full api + web suites, both tsc runs, biome on touched files green; api and web rebuilt and running.
</verification>
<success_criteria>
- A subscribed user receives exactly one mail and one on-screen notification when a cloud fails twice in a row or turns red for maintenance, DB upgrade or expired support, and exactly one "wieder in Ordnung" when it recovers.
- No duplicate notification across concurrent checks, restarts or repeated red checks.
- Single failures do not alert; real outages are reported within about five minutes.
- No user-facing text names a tenant; nothing pushed.
## Source coverage audit
| Source | Item | Covered by |
|---|---|---|
| GOAL | Per-user notification on red and recovery | Tasks 1-3 |
| CONTEXT | L-01 bell, table, RLS, cascade, USE-level toggles, `subscribed` in list | Task 1 |
| CONTEXT | L-02 transition once, no repeat, recovery, state on row, claim | Task 1 (+ recovery via address change Task 2) |
| CONTEXT | L-03 two-strike guard, tile keeps last good + hint, immediate other reasons, ~5 min retry | Task 1 (rules), Task 2 (retry, hint) |
| CONTEXT | L-04 mail like reminders + in-app/desktop notification | Task 1 (mail), Task 3 (in-app) |
| CONTEXT | L-05 German mail texts, locked subjects, link | Task 1 |
| CONTEXT | L-06 re-check access and active state at send time | Task 1 |
| CONTEXT | L-07 tests, full suites, tsc, biome | Tasks 1-3 |
| CONTEXT | L-08 CHANGELOG + docs | Task 3 |
| CONTEXT | L-09 local migration + rebuild | Task 1 (migration), Task 3 (rebuild) |
| CONTEXT | L-10 no tenant wording, no push, SUMMARY test path + SMTP status | Tasks 1-3, SUMMARY in Task 3 |
</success_criteria>
<output>
Create `.planning/quick/261002-kxc-nextcloud-status-benachrichtigung-bei-ro/261002-kxc-SUMMARY.md` when done
</output>
@@ -0,0 +1,155 @@
---
phase: quick-261002-kxc
plan: 01
subsystem: nextcloud-status
tags: [benachrichtigung, glocke, mail, rls, scheduler, in-app]
status: complete
requires:
- quick-261002-k67 (Modul Nextcloud-Status)
provides:
- persönliche Glocke je Kachel (Tabelle NextcloudAlertSubscription, RLS mit Benutzerdimension)
- Zwei-Fehlschläge-Regel, Wiederholung nach 5 Minuten, gemeldeter Zustand auf der Zeile
- E-Mail (nur Deutsch) und Meldung in Tessera bei Störung und Wiederherstellung
- Fehlercodes als lesbarer Hinweis auf Kachel und in der Mail
affects:
- apps/api/src/nextcloud-status/
- apps/api/src/mail/mail.service.ts
- apps/web (Kachel, Seite, AppShell)
key-files:
created:
- apps/api/prisma/migrations/20261002170000_nextcloud_alerts/migration.sql
- apps/api/src/nextcloud-status/nextcloud-alert-rules.ts
- apps/api/src/nextcloud-status/nextcloud-alert-mail.ts
- apps/api/src/nextcloud-status/nextcloud-alert.service.ts
- apps/web/src/components/nextcloud-status/nextcloud-alert-notifier.tsx
- apps/web/src/components/nextcloud-status/error-hint.ts
modified:
- apps/api/prisma/schema.prisma
- apps/api/src/nextcloud-status/nextcloud-status.service.ts
- apps/api/src/nextcloud-status/nextcloud-status.controller.ts
- apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.ts
- apps/api/src/nextcloud-status/nextcloud-status.module.ts
- apps/api/src/mail/mail.service.ts
- apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudTile.tsx
- apps/web/src/app/(portal)/modules/nextcloud-status/page.tsx
- apps/web/src/components/layout/app-shell.tsx
- docs/mandantentrennung-zugriffsklassifikation.md
- CHANGELOG.md
- docs/anleitung-anwender.md
- docs/anleitung-administration.md
decisions:
- "Zwei-Fehlschläge-Regel für jeden fehlgeschlagenen Abruf (D-K1); der erste Fehlschlag schreibt nur Zähler und Zeitpunkt, die Kachel behält den guten Stand (D-K2)"
- "Der Zähler einer dauerhaft roten Cloud wird bei 2 gedeckelt (sonst wüchse er stündlich weiter)"
- "Wiederholung als zweiter Cron-Auftrag (jede Minute) über denselben einen Systemlesezugriff (D-K3)"
- "Anspruch vor dem Senden per updateMany auf alertState; Versand im Hintergrund, bis zu 3 Versuche im Abstand von 60 s (D-K5)"
- "Fehler-Hinweise: gleiche Zuordnung in API (Mail) und Web (Kachel); Rohkennung bleibt als Tooltip"
metrics:
duration: "ca. 20 Minuten"
completed: 2026-10-02
actuals:
tokens: 32500
tasks: 3
commits: 3
plan_head_before: 6829c44464ef3d68711117af656d4c6db79892ec
plan_head_after: 6c4bff6f6cf85867cdea54c1dbcc91d2c7fff028
---
# Quick 261002-kxc: Nextcloud-Status – Benachrichtigung bei Rot und Wiederherstellung
Persönliche Glocke je Kachel: Wer eingeschaltet hat, bekommt bei Störung und bei „wieder in Ordnung“ je Änderung genau eine E-Mail und eine Meldung in Tessera (Desktop-App: Windows-Benachrichtigung). Ein einzelner Fehlschlag löst nichts aus; Tessera prüft nach etwa fünf Minuten erneut.
## Commits
| Aufgabe | Commit | Inhalt |
|---|---|---|
| 1 (Tracer) | faed0d7 | Migration, reine Regeln, Mailbaustein, Alert-Dienst, Glocke in API und Kachel, RLS-Dokument |
| 2 | 11a70c9 | Wiederholungsauftrag, Adresswechsel, Hinweis „Prüfung fehlgeschlagen“, Fehlercodes in Klartext |
| 3 | 6c4bff6 | Endpunkt `GET alerts`, globaler Melder im AppShell, Anleitung, Changelog |
Nicht gepusht. Die Commit-Zeile trägt „Claude Sonnet 5.5“ (die Vorgabe der Umgebung für Commit-Anhänge), nicht den im Auftrag genannten Opus-Text, weil ich Sonnet 5.5 bin und die Angabe sonst falsch wäre.
## Was gebaut wurde
- **Datenbank:** Migration `20261002170000_nextcloud_alerts` (lokal angewendet, `migrate status` aktuell, Drift-Prüfung `--exit-code` = 0). Neue Tabelle `NextcloudAlertSubscription` mit Mandant-und-Benutzer-Regel wie „Reminder“, ohne `system_read_policy`, Cascade bei Cloud und Benutzer. Neue Spalten an `NextcloudInstance`: `consecutiveFailures`, `firstFailureAt`, `alertState` ('ok'|'red'), `alertReason`, `alertChangedAt`.
- **Reine Regeln** (`nextcloud-alert-rules.ts`): `decideAlert`, `planStatusWrite`, `FAILURES_FOR_RED = 2`, `RETRY_DELAY_MS = 5 min`.
- **Mail** (`nextcloud-alert-mail.ts`, `MailService.sendNextcloudAlertEmail`): Betreff „Nextcloud <Kundenname>: nicht erreichbar“ bzw. „… wieder in Ordnung“ wortgleich, eigene Betreffe für die anderen roten Gründe, Text mit Grund, Adresse, Zeit (Europe/Berlin), Link zum Modul; CR/LF und 150 Zeichen wie bei Erinnerungen.
- **Alert-Dienst:** Abonnieren/Abbestellen (idempotent, 404 für fremde Clouds), Anspruch vor dem Senden (`updateMany where alertState = bisher`, nur `count === 1` meldet), Versand im Hintergrund, Empfänger beim Senden neu geprüft (aktiv, Adresse, Modulzugriff über `getModuleAccessLevels`, SMTP), bis zu 3 Versuche, `listRecentAlerts` für die Meldung in Tessera.
- **Status-Dienst/Controller:** `checkInstance` ist weiter der einzige Schreibweg und läuft jetzt über `planStatusWrite` und `evaluateAfterCheck`; Listen tragen `subscribed`; `POST/DELETE instances/:id/subscription` und `GET alerts` nur mit Modul-Guard (kein Verwalten, keine Rolle).
- **Planer:** zweiter Auftrag `nextcloud-status-retry` (jede Minute), filtert dieselbe `forSystem`-Abfrage auf genau einen Fehlschlag älter als 5 Minuten. Weiterhin genau ein `forSystem`-Aufruf im Dienst.
- **Web:** Glocke an jeder Kachel (auch für „Benutzen“), optimistisches Umschalten mit Rücknahme bei Fehler, Browser-Erlaubnis einmalig beim ersten Einschalten; Hinweis „Prüfung fehlgeschlagen, wird in wenigen Minuten wiederholt“; `NextcloudAlertNotifier` im AppShell (nutzt die Helfer der Erinnerungen, daher Windows-Benachrichtigung in der Desktop-App; fragt ohne Modulzugriff nie ab; 401/403 pausiert bis Fokus).
- **Zusatzwunsch Fehlertexte:** Kachel und Mail zeigen statt `ERR_TLS_CERT_ALTNAME_INVALID` & Co. Klartext: „Zertifikat passt nicht zur Adresse“, „Zertifikat abgelaufen“, „Zertifikat nicht vertrauenswürdig“, „Adresse nicht gefunden“ (ENOTFOUND/EAI_AGAIN), „Verbindung abgelehnt“, „Zeitüberschreitung“, „Server antwortet mit Fehler <Code>“, sonst „Verbindungsfehler“. Auf der Kachel bleibt die Rohkennung als `title`-Tooltip. In der Mail steht nur der Klartext. Zuordnung als reine Funktionen `describeCheckError` (API) und `errorHint` (Web), je mit Tests; de/en-Texte unter `nextcloudStatus.errorHint`.
## Tests und Prüfungen (gemessen)
| Prüfung | Ergebnis |
|---|---|
| API komplett (`pnpm --filter @tessera/api test`) | 121 Dateien, 2120 Tests, alle grün |
| Web komplett (`pnpm --filter @tessera/web test`) | 121 Dateien, 1321 Tests, alle grün |
| `tsc --noEmit` API / Web | beide fehlerfrei |
| Biome `lint` auf allen berührten Dateien der drei Aufgaben (25 Dateien) | keine Fehler, 2 Warnungen in `mail.service.spec.ts` (Zeilen 130 und 150, Non-Null-Zusicherungen, vor dieser Arbeit vorhanden, nicht angefasst) |
| Biome `check` auf den neuen Dateien und dem AppShell | sauber |
| RLS-Specs (`rls-coverage`, `rls-access-inventory`) | grün, Dokument nachgeführt |
| Migration | angewendet, Drift 0 |
Neu hinzugekommen: Regeln 19 Tests, Mailbaustein 27, Alert-Dienst 22, Web-Melder 11, Fehlerhinweis 20 u. a. sowie Erweiterungen in Status-Dienst, Controller, Scheduler, MailService und Seitentest.
Hinweis zur Reihenfolge: Bei den reinen Regeln habe ich Spec und Implementierung gemeinsam geschrieben und nicht ausdrücklich zuerst den roten Lauf beobachtet; die Fälle aus dem Plan sind vollständig abgedeckt.
## RLS-Dokument (`docs/mandantentrennung-zugriffsklassifikation.md`)
- Bereichszeile `nextcloud-status`: 0/23/1 (gemessen mit der Gate-Schleife über `nextcloud-status/`; Dienst 14, Alert-Dienst 9 gebundene Rohtreffer).
- Neue Fundstellen: drei Paare `nextcloud-alert.service.ts` (`nextcloudAlertSubscription`, `nextcloudInstance`, `user`), Paarzahl 96 (53→56 `muss-mandantengebunden`).
- Summenzeile von mir nur um die eigenen +9 fortgeschrieben (61/273/8). **Auffälligkeit:** Eine frische Messung über alle Bereiche ergibt 61/280/8; die Differenz von 7 stammt aus älteren, nicht nachgeführten Zeilen (`dashboard` 30 statt 29, `groups` 33 statt 31, `reminders` 13 statt 12, weitere Bereiche ohne eigene Zeile). Das habe ich nicht stillschweigend „repariert“, sondern in der Summenzeile vermerkt.
- `FORSYSTEM_ALLOWED_CALL_SITES` unverändert; genau ein `forSystem(this.prisma)` in `nextcloud-status.service.ts`.
## Abweichungen vom Plan
**1. [Rule 3 - Blockierend] Umlaut-Wächter**
- Gefunden bei Aufgabe 2: `umlaut-guard.spec.ts` meldete „passt“ und „vertrauenswürdig“ als neue Wörter.
- Behoben: beide in `UMLAUT_ALLOWLIST` (`apps/web/src/messages/umlaut-dictionary.ts`) ergänzt (korrektes Deutsch).
**2. [Rule 2 - Korrektheit] Zähler gedeckelt**
- `consecutiveFailures` einer dauerhaft roten Cloud würde sonst bei jeder stündlichen Prüfung weiterzählen; gedeckelt bei 2 (`FAILURES_FOR_RED`). Mit Test.
**3. Zusatzwunsch Fehlertexte** (Auftrag, nicht im Plan): zusätzlich neue Dateien `error-hint.ts`/`.test.ts` und `describeCheckError` in `nextcloud-alert-mail.ts`; Doku-Satz „Fehlercode steht klein darunter“ in der Administrationsanleitung angepasst.
**4. Commit-Anhang:** siehe oben (Sonnet 5.5 statt Opus-Text).
Sonst: Plan wie geschrieben ausgeführt. Keine Authentifizierungs-Hürden, keine Paketinstallationen.
## Lokaler Neubau
`docker compose up -d --build api web` ausgeführt; api, web und db laufen. Im API-Protokoll: „Nextcloud-Status module seeded in registry“, „Nextcloud-Status retry job registered: * * * * *“, Routen `/modules/nextcloud-status/instances/:id/subscription` (POST, DELETE) und `/modules/nextcloud-status/alerts` (GET) gemappt, „No pending migrations to apply“, keine Fehler.
## Für die Browser-Prüfung (Orchestrator)
**Lokaler SMTP-Stand:** Ja, eingerichtet. Genau eine `SmtpConfig`-Zeile: Host `mailhog`, Port 1025, Absender `tessera@tessera.local`. Die Mails landen also in MailHog (nicht in einem echten Postfach). Empfangsadressen der lokalen Konten (nur nachgesehen, nichts geändert): `admin` (SUPER_ADMIN) `admin@tessera.local`, `nutzer1` `nutzer1@tessera.local`, `nutzer2` `nutzer2@tessera.local`, `testuser` `testuser@example.com`; alle aktiv. Lokal gibt es bereits 5 Clouds. Ich habe keinen Versand ausgelöst (nur Unit-Tests mit gemocktem Transport).
**Klickweg, beide Übergänge zu erzwingen** (als Admin; ein Benutzer mit nur „Benutzen“ sieht die Glocke ebenfalls, kann aber keine Clouds anlegen oder prüfen):
1. „Cloud hinzufügen“ mit einer nicht erreichbaren Adresse, z. B. `https://127.0.0.1:9`. Die Kachel bleibt grau „Noch nicht geprüft“ und zeigt den Hinweis „Prüfung fehlgeschlagen, wird in wenigen Minuten wiederholt“.
2. Glocke auf dieser Kachel einschalten (Browser fragt einmalig nach der Erlaubnis für Benachrichtigungen).
3. „Jetzt prüfen“ oder den Prüfknopf der Kachel noch einmal drücken: zweiter Fehlschlag, Kachel wird rot „Nicht erreichbar“ mit Klartext-Grund (bei 127.0.0.1:9 „Verbindung abgelehnt“). Es kommt eine Mail „Nextcloud <Name>: nicht erreichbar“ (in MailHog) und die Meldung in Tessera. Ohne Knopfdruck passiert dasselbe automatisch nach etwa 5 Minuten durch den Wiederholungsauftrag. Weitere Prüfungen, solange die Cloud rot bleibt, senden nichts mehr.
4. Adresse der Cloud bearbeiten auf eine erreichbare öffentliche Nextcloud: sie wird sofort neu geprüft, die Kachel wird grün oder gelb, es kommt „Nextcloud <Name>: wieder in Ordnung“ (Mail und Meldung).
Sofort rot ohne Wartezeit: eine Cloud, die im Wartungsmodus steht oder auf eine Datenbank-Aktualisierung wartet, wird beim ersten Abruf gemeldet.
## Known Stubs
Keine.
## Threat Flags
Keine neuen Angriffsflächen außerhalb des Plan-Bedrohungsmodells (T-kxc-01 bis -08 umgesetzt: Benutzer nur aus dem Token, 404 für fremde Clouds, Empfänger beim Senden neu geprüft, Anspruch vor dem Senden, CR/LF-Schutz im Betreff, Versand im Hintergrund, keine Verwalten-/Rollen-Decorators an den neuen Handlern, durch Tests festgeschrieben).
## Self-Check: PASSED
- Dateien vorhanden: Migration, `nextcloud-alert-rules.ts`, `nextcloud-alert-mail.ts`, `nextcloud-alert.service.ts`, `nextcloud-alert-notifier.tsx`, `error-hint.ts` – gefunden.
- Commits vorhanden: faed0d7, 11a70c9, 6c4bff6 (gemessen mit `git rev-list --count 6829c44..HEAD` = 3).
## Browser-Prüfung (Orchestrator, 02.10., lokal, dunkel, MailHog)
- Grundmodul (k67): 5 echte öffentliche Clouds + 1 kaputte; Ampel korrekt (35.0.1/34.0.4 grün, 33.0.5/33.0.8 Enterprise gelb „Update auf 33.0.9“, Zertifikatsfehler rot), Sortierung Status, Logo per Upload und per URL, Dashboard-Kachel mit Zählern 2/2/1.
- Glocke an „Ausfall AG“ (https://127.0.0.1:9): erster Abruf grau + Wiederholungshinweis, zweiter rot „Nicht erreichbar“ (Port 9 ist von fetch gesperrt → „Verbindungsfehler“ korrekt; normaler Port liefert ECONNREFUSED → „Verbindung abgelehnt“).
- Mail „Nextcloud Ausfall AG: nicht erreichbar“ in MailHog, Text verständlich; Browser-Benachrichtigung erschienen.
- Adresse auf erreichbare Cloud geändert → grün, Mail + Benachrichtigung „wieder in Ordnung“.