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

307 lines
48 KiB
Markdown

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