Files
tessera-ctl/.planning/quick/261002-kxc-nextcloud-status-benachrichtigung-bei-ro/261002-kxc-PLAN.md
T
schalli 14674fced3
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
docs(quick-261002-kxc): Nextcloud-Status Benachrichtigung
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 15:37:43 +02:00

48 KiB

phase, plan, type, wave, depends_on, quick_id, description, date, files_modified, autonomous, requirements, estimate, must_haves
phase plan type wave depends_on quick_id description date files_modified autonomous requirements estimate must_haves
quick-261002-kxc 01 execute 1
261002-kxc Nextcloud-Status: persönliche Benachrichtigung (Glocke je Kachel) bei Störung und Wiederherstellung, per E-Mail und in Tessera 2026-10-02
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
apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.ts
apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.spec.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
CHANGELOG.md
docs/anleitung-anwender.md
docs/anleitung-administration.md
true
QUICK-261002-kxc
tokens raw_tokens tasks confidence
150000 150000 3 low
truths artifacts key_links
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)
path provides contains
apps/api/prisma/migrations/20261002170000_nextcloud_alerts/migration.sql NextcloudAlertSubscription table (RLS with user dimension, cascades) + alert/failure columns on NextcloudInstance NextcloudAlertSubscription
path provides exports
apps/api/src/nextcloud-status/nextcloud-alert-rules.ts pure decideAlert + planStatusWrite + retry constants
decideAlert
planStatusWrite
FAILURES_FOR_RED
RETRY_DELAY_MS
path provides exports
apps/api/src/nextcloud-status/nextcloud-alert-mail.ts pure German mail builder (subject + plain text)
buildNextcloudAlertMail
path provides
apps/api/src/nextcloud-status/nextcloud-alert.service.ts subscriptions, claim-before-send, recipient re-check, mail delivery with 3 attempts, recent alerts per user
path provides
apps/web/src/components/nextcloud-status/nextcloud-alert-notifier.tsx global in-app notifier mounted in AppShell
from to via pattern
NextcloudStatusService.checkInstance planStatusWrite -> rateNextcloud -> NextcloudAlertService.evaluateAfterCheck every check (hourly, retry, manual, create, URL change) runs the guard and the transition claim evaluateAfterCheck(
from to via pattern
NextcloudAlertService.evaluateAfterCheck nextcloudInstance.updateMany where alertState = previous state claim-before-send: only count === 1 notifies alertState: prev
from to via pattern
NextcloudStatusSchedulerService retry job NextcloudStatusService.loadAllInstancesForScheduler({ retryDueBefore }) same single forSystem call site, filtered to consecutiveFailures = 1 retryDueBefore
from to via pattern
apps/web/src/components/layout/app-shell.tsx NextcloudAlertNotifier -> GET /modules/nextcloud-status/alerts -> showReminderNotification global mount next to ReminderNotifier <NextcloudAlertNotifier />
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 : nicht erreichbar" / "Nextcloud : 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.

<execution_context> @/.claude/gsd-core/workflows/execute-plan.md @/.claude/gsd-core/templates/summary.md </execution_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.
Task 1 (Tracer): Glocke einschalten -> Cloud fällt zweimal aus -> genau eine Mail an den Abonnenten 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 `docker ps` lists `tessera-ctl-db-1` as running (needed for the local migration). - 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 : nicht erreichbar"; up -> "Nextcloud : wieder in Ordnung"; body contains reason line, URL, time (Europe/Berlin) and "/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 **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@: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 "" has a problem since

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. 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 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.

Task 2: Wiederholung nach 5 Minuten, Adresswechsel und Hinweis „Prüfung fehlgeschlagen“ auf der Kachel 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 - 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 **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. 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" 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.

Task 3: In-App-Meldung (Desktop: Windows-Benachrichtigung), Anleitung, Changelog, Abschluss und Neubau 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 - 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 : nicht erreichbar", for up "Nextcloud : wieder in Ordnung", body is the address; 401/403 pauses polling until focus **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. 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" 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.

<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>
- 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.

<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>
Create `.planning/quick/261002-kxc-nextcloud-status-benachrichtigung-bei-ro/261002-kxc-SUMMARY.md` when done