);
}
diff --git a/apps/web/src/components/nextcloud-status/sort-clouds.test.ts b/apps/web/src/components/nextcloud-status/sort-clouds.test.ts
new file mode 100644
index 0000000..63f770f
--- /dev/null
+++ b/apps/web/src/components/nextcloud-status/sort-clouds.test.ts
@@ -0,0 +1,107 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import type { NextcloudInstance, RatingLevel } from '@/lib/nextcloud-status-api';
+import { readSortPreference, sortClouds, writeSortPreference } from './sort-clouds';
+
+function make(
+ name: string,
+ level: RatingLevel = 'green',
+ version: string | null = '35.0.1',
+ eolDate: string | null = null,
+): NextcloudInstance {
+ return {
+ id: name,
+ customerName: name,
+ baseUrl: `https://${name}.de`,
+ logoUrl: null,
+ hasUploadedLogo: false,
+ logoVersion: 0,
+ status: {
+ checkedAt: null,
+ reachable: true,
+ maintenance: false,
+ needsDbUpgrade: false,
+ versionString: version,
+ edition: null,
+ errorKind: null,
+ errorDetail: null,
+ },
+ rating: { level, reason: 'current', updateTo: null, eolDate, cycle: null },
+ };
+}
+
+const names = (list: NextcloudInstance[]) => list.map((i) => i.customerName);
+
+describe('sortClouds', () => {
+ it('name: A-Z mit deutscher Sortierung, ohne Beachtung der Gross-/Kleinschreibung', () => {
+ const list = [make('zebra'), make('Ärger'), make('Anton'), make('berta')];
+ expect(names(sortClouds(list, 'name'))).toEqual(['Anton', 'Ärger', 'berta', 'zebra']);
+ });
+
+ it('status: rot, gelb, grau, gruen, bei Gleichstand nach Name', () => {
+ const list = [
+ make('g2', 'green'),
+ make('r2', 'red'),
+ make('u1', 'unknown'),
+ make('y1', 'yellow'),
+ make('r1', 'red'),
+ make('g1', 'green'),
+ ];
+ expect(names(sortClouds(list, 'status'))).toEqual(['r1', 'r2', 'y1', 'u1', 'g1', 'g2']);
+ });
+
+ it('version: aelteste zuerst, ohne Version zuletzt', () => {
+ const list = [
+ make('a', 'green', '34.0.10'),
+ make('b', 'green', null),
+ make('c', 'green', '33.0.9'),
+ make('d', 'green', '34.0.2'),
+ ];
+ expect(names(sortClouds(list, 'version'))).toEqual(['c', 'd', 'a', 'b']);
+ });
+
+ it('eol: frueheste zuerst, ohne Datum zuletzt', () => {
+ const list = [
+ make('a', 'green', '1.0.0', '2027-06-30'),
+ make('b'),
+ make('c', 'green', '1.0.0', '2026-09-30'),
+ ];
+ expect(names(sortClouds(list, 'eol'))).toEqual(['c', 'a', 'b']);
+ });
+
+ it('veraendert die Eingabe nicht', () => {
+ const list = [make('b'), make('a')];
+ const copy = [...list];
+ sortClouds(list, 'name');
+ expect(list).toEqual(copy);
+ });
+});
+
+describe('Sortier-Vorliebe', () => {
+ afterEach(() => {
+ window.localStorage.clear();
+ vi.restoreAllMocks();
+ });
+
+ it('merkt die Auswahl je Benutzer unter eigenem Schluessel', () => {
+ writeSortPreference('u1', 'status');
+ expect(window.localStorage.getItem('tessera:nextcloud-status:sort:u1')).toBe('status');
+ expect(readSortPreference('u1')).toBe('status');
+ expect(readSortPreference('u2')).toBe('name');
+ });
+
+ it('ein unbekannter gespeicherter Wert ergibt name', () => {
+ window.localStorage.setItem('tessera:nextcloud-status:sort:u1', 'quatsch');
+ expect(readSortPreference('u1')).toBe('name');
+ });
+
+ it('ein gesperrter Speicher wirft nie', () => {
+ vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
+ throw new Error('gesperrt');
+ });
+ vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
+ throw new Error('gesperrt');
+ });
+ expect(readSortPreference('u1')).toBe('name');
+ expect(() => writeSortPreference('u1', 'eol')).not.toThrow();
+ });
+});
diff --git a/apps/web/src/components/nextcloud-status/sort-clouds.ts b/apps/web/src/components/nextcloud-status/sort-clouds.ts
new file mode 100644
index 0000000..b8011ee
--- /dev/null
+++ b/apps/web/src/components/nextcloud-status/sort-clouds.ts
@@ -0,0 +1,83 @@
+import type { NextcloudInstance, RatingLevel } from '@/lib/nextcloud-status-api';
+
+/**
+ * Sortierung der Cloud-Kacheln (quick-261002-k67, L-07, D-G) — reine
+ * Funktionen, kein React.
+ */
+export type SortKey = 'name' | 'status' | 'version' | 'eol';
+
+export const SORT_KEYS: readonly SortKey[] = ['name', 'status', 'version', 'eol'];
+
+/** Rot zuerst, dann Gelb, Grau, Gruen. */
+const LEVEL_ORDER: Record = { red: 0, yellow: 1, unknown: 2, green: 3 };
+
+function byName(a: NextcloudInstance, b: NextcloudInstance): number {
+ return a.customerName.localeCompare(b.customerName, 'de', { sensitivity: 'base' });
+}
+
+function versionTriple(raw: string | null): [number, number, number] | null {
+ if (!raw) return null;
+ const m = /^(\d+)\.(\d+)\.(\d+)/.exec(raw.trim());
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
+}
+
+function compareVersions(a: NextcloudInstance, b: NextcloudInstance): number {
+ const va = versionTriple(a.status.versionString);
+ const vb = versionTriple(b.status.versionString);
+ if (!va && !vb) return 0;
+ if (!va) return 1; // unbekannt zuletzt
+ if (!vb) return -1;
+ for (let i = 0; i < 3; i++) {
+ if (va[i] !== vb[i]) return va[i] - vb[i];
+ }
+ return 0;
+}
+
+function compareEol(a: NextcloudInstance, b: NextcloudInstance): number {
+ const ea = a.rating.eolDate;
+ const eb = b.rating.eolDate;
+ if (!ea && !eb) return 0;
+ if (!ea) return 1; // unbekannt zuletzt
+ if (!eb) return -1;
+ return ea < eb ? -1 : ea > eb ? 1 : 0;
+}
+
+/** Liefert eine neue, sortierte Liste; die Eingabe bleibt unveraendert. Gleiche Werte nach Kundenname. */
+export function sortClouds(items: NextcloudInstance[], key: SortKey): NextcloudInstance[] {
+ const compare = (a: NextcloudInstance, b: NextcloudInstance): number => {
+ switch (key) {
+ case 'status':
+ return LEVEL_ORDER[a.rating.level] - LEVEL_ORDER[b.rating.level];
+ case 'version':
+ return compareVersions(a, b);
+ case 'eol':
+ return compareEol(a, b);
+ default:
+ return 0;
+ }
+ };
+ return [...items].sort((a, b) => compare(a, b) || byName(a, b));
+}
+
+function storageKey(userId: string): string {
+ return `tessera:nextcloud-status:sort:${userId}`;
+}
+
+/** Gemerkte Sortierung dieses Benutzers; unbekannter Wert oder gesperrter Speicher ergeben 'name'. */
+export function readSortPreference(userId: string): SortKey {
+ try {
+ const stored = window.localStorage.getItem(storageKey(userId));
+ return (SORT_KEYS as readonly string[]).includes(stored ?? '') ? (stored as SortKey) : 'name';
+ } catch {
+ return 'name';
+ }
+}
+
+/** Merkt die Sortierung; ein gesperrter Speicher wird still uebergangen. */
+export function writeSortPreference(userId: string, key: SortKey): void {
+ try {
+ window.localStorage.setItem(storageKey(userId), key);
+ } catch {
+ /* Speicher gesperrt oder voll — die Auswahl gilt dann nur fuer diese Sitzung */
+ }
+}
diff --git a/apps/web/src/lib/nextcloud-status-api.ts b/apps/web/src/lib/nextcloud-status-api.ts
index 85fce54..2413f2c 100644
--- a/apps/web/src/lib/nextcloud-status-api.ts
+++ b/apps/web/src/lib/nextcloud-status-api.ts
@@ -84,3 +84,93 @@ export function logoSrc(
}
return instance.logoUrl ?? null;
}
+
+/** Liest die NestJS-Fehlermeldung aus dem Antwortkoerper, faellt sonst auf einen Standardtext zurueck. */
+async function readErrorMessage(res: Response, fallback: string): Promise {
+ try {
+ const body = await res.json();
+ if (typeof body?.message === 'string') return body.message;
+ if (Array.isArray(body?.message) && body.message.length > 0) return String(body.message[0]);
+ } catch {
+ /* Antwort war kein JSON — Standardtext bleibt */
+ }
+ return fallback;
+}
+
+export interface NextcloudInstancePayload {
+ customerName: string;
+ baseUrl: string;
+ /** Leere Zeichenkette = Logo-Adresse entfernen (nur beim Aendern). */
+ logoUrl?: string;
+}
+
+const BASE = `${API_URL}/modules/nextcloud-status/instances`;
+
+/** POST /instances — Cloud anlegen und sofort pruefen (nur Verwalten). */
+export async function createInstance(
+ payload: NextcloudInstancePayload,
+): Promise {
+ const res = await fetch(BASE, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify(payload),
+ });
+ if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to create nextcloud instance'));
+ return res.json();
+}
+
+/** PUT /instances/:id — Cloud aendern (nur Verwalten). */
+export async function updateInstance(
+ id: string,
+ payload: Partial,
+): Promise {
+ const res = await fetch(`${BASE}/${id}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify(payload),
+ });
+ if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to update nextcloud instance'));
+ return res.json();
+}
+
+/** DELETE /instances/:id — Cloud entfernen (nur Verwalten). */
+export async function deleteInstance(id: string): Promise {
+ const res = await fetch(`${BASE}/${id}`, { method: 'DELETE', credentials: 'include' });
+ if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to delete nextcloud instance'));
+}
+
+/** POST /instances/check — "Jetzt pruefen" fuer die ganze Liste, liefert die frische Liste. */
+export async function checkAll(): Promise {
+ const res = await fetch(`${BASE}/check`, { method: 'POST', credentials: 'include' });
+ if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to check nextcloud instances'));
+ return res.json();
+}
+
+/** POST /instances/:id/check — eine Cloud sofort pruefen, liefert die aktualisierte Kachel. */
+export async function checkOne(id: string): Promise {
+ const res = await fetch(`${BASE}/${id}/check`, { method: 'POST', credentials: 'include' });
+ if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to check nextcloud instance'));
+ return res.json();
+}
+
+/** POST /instances/:id/logo — Logo hochladen (Formularfeld `logo`, nur Verwalten). */
+export async function uploadLogo(id: string, file: File): Promise {
+ const form = new FormData();
+ form.append('logo', file);
+ const res = await fetch(`${BASE}/${id}/logo`, {
+ method: 'POST',
+ credentials: 'include',
+ body: form,
+ });
+ if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to upload logo'));
+ return res.json();
+}
+
+/** DELETE /instances/:id/logo — hochgeladenes Logo entfernen (nur Verwalten). */
+export async function removeLogo(id: string): Promise {
+ const res = await fetch(`${BASE}/${id}/logo`, { method: 'DELETE', credentials: 'include' });
+ if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to remove logo'));
+ return res.json();
+}
diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json
index b154af4..f66c2c0 100644
--- a/apps/web/src/messages/de.json
+++ b/apps/web/src/messages/de.json
@@ -1851,6 +1851,50 @@
"noReference": "Bewertung nicht möglich",
"versionUnknown": "Version unbekannt",
"notChecked": "Noch nicht geprüft"
+ },
+ "sort": {
+ "label": "Sortieren nach",
+ "name": "Kundenname",
+ "status": "Status",
+ "version": "Version",
+ "eol": "Support-Ende"
+ },
+ "actions": {
+ "add": "Cloud hinzufügen",
+ "checkAll": "Jetzt prüfen",
+ "checking": "Wird geprüft …"
+ },
+ "cardActions": {
+ "refresh": "Diese Cloud jetzt prüfen",
+ "edit": "Cloud bearbeiten"
+ },
+ "form": {
+ "addTitle": "Cloud hinzufügen",
+ "editTitle": "Cloud bearbeiten",
+ "customerName": "Kundenname",
+ "baseUrl": "Adresse der Cloud",
+ "baseUrlPlaceholder": "https://cloud.example.com",
+ "baseUrlHint": "Tessera ruft nur die öffentliche Statusseite dieser Adresse ab. Zugangsdaten werden nicht benötigt.",
+ "logo": "Logo",
+ "logoNone": "Kein Logo",
+ "logoUpload": "Bild hochladen",
+ "logoUrl": "Bildadresse (https)",
+ "logoUrlPlaceholder": "https://www.example.com/logo.png",
+ "logoUrlHint": "Die Adresse muss mit https:// beginnen. Das Bild wird direkt von Ihrem Browser geladen.",
+ "logoFileHint": "PNG, JPEG, GIF oder WebP, höchstens 1 MB.",
+ "logoFileTooLarge": "Die Datei ist größer als 1 MB.",
+ "logoCurrent": "Aktuelles Logo",
+ "logoRemove": "Logo entfernen",
+ "save": "Speichern",
+ "saving": "Wird geprüft …",
+ "cancel": "Abbrechen",
+ "delete": "Cloud entfernen",
+ "deleteConfirm": "Möchten Sie die Cloud „{name}“ wirklich entfernen?",
+ "deleteConfirmYes": "Endgültig entfernen",
+ "required": "Bitte füllen Sie Kundenname und Adresse aus.",
+ "saveError": "Die Cloud konnte nicht gespeichert werden.",
+ "deleteError": "Die Cloud konnte nicht entfernt werden.",
+ "close": "Dialog schließen"
}
}
}
diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json
index 772c9c0..f267064 100644
--- a/apps/web/src/messages/en.json
+++ b/apps/web/src/messages/en.json
@@ -1851,6 +1851,50 @@
"noReference": "Rating not possible",
"versionUnknown": "Version unknown",
"notChecked": "Not checked yet"
+ },
+ "sort": {
+ "label": "Sort by",
+ "name": "Customer name",
+ "status": "Status",
+ "version": "Version",
+ "eol": "End of support"
+ },
+ "actions": {
+ "add": "Add cloud",
+ "checkAll": "Check now",
+ "checking": "Checking …"
+ },
+ "cardActions": {
+ "refresh": "Check this cloud now",
+ "edit": "Edit cloud"
+ },
+ "form": {
+ "addTitle": "Add cloud",
+ "editTitle": "Edit cloud",
+ "customerName": "Customer name",
+ "baseUrl": "Cloud address",
+ "baseUrlPlaceholder": "https://cloud.example.com",
+ "baseUrlHint": "Tessera only reads the public status page of this address. No login data is needed.",
+ "logo": "Logo",
+ "logoNone": "No logo",
+ "logoUpload": "Upload image",
+ "logoUrl": "Image address (https)",
+ "logoUrlPlaceholder": "https://www.example.com/logo.png",
+ "logoUrlHint": "The address must start with https://. The image is loaded directly by your browser.",
+ "logoFileHint": "PNG, JPEG, GIF or WebP, at most 1 MB.",
+ "logoFileTooLarge": "The file is larger than 1 MB.",
+ "logoCurrent": "Current logo",
+ "logoRemove": "Remove logo",
+ "save": "Save",
+ "saving": "Checking …",
+ "cancel": "Cancel",
+ "delete": "Remove cloud",
+ "deleteConfirm": "Do you really want to remove the cloud “{name}”?",
+ "deleteConfirmYes": "Remove permanently",
+ "required": "Please fill in customer name and address.",
+ "saveError": "The cloud could not be saved.",
+ "deleteError": "The cloud could not be removed.",
+ "close": "Close dialog"
}
}
}
diff --git a/apps/web/src/messages/umlaut-dictionary.ts b/apps/web/src/messages/umlaut-dictionary.ts
index 24b45b0..e428d95 100644
--- a/apps/web/src/messages/umlaut-dictionary.ts
+++ b/apps/web/src/messages/umlaut-dictionary.ts
@@ -106,6 +106,8 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
// quick-261002-k67: Nextcloud-Status (korrektes Deutsch)
'Neueste',
'ausstehend',
+ 'Statusseite',
+ 'Bildadresse',
// quick-261001-l4q: Zertifikatsmodul, Übersicht (korrektes Deutsch)
'Aussteller',
'Betriebssystemen',
diff --git a/docs/mandantentrennung-zugriffsklassifikation.md b/docs/mandantentrennung-zugriffsklassifikation.md
index befa258..9f69bc1 100644
--- a/docs/mandantentrennung-zugriffsklassifikation.md
+++ b/docs/mandantentrennung-zugriffsklassifikation.md
@@ -180,8 +180,8 @@ Spalten sind mit der Schleife aus dem Gate von 260914-eym nachgerechnet
| reminders | 0 | 12 | 1 | **quick-260929-if2 (Aufgabe 3):** nachgemessen mit der Gate-Schleife: 0/12/1 — +5 gebunden, +1 System. `reminders.service.ts` +1 gebunden (`getEmailAvailability`: `user.findFirst` für die eigene E-Mail-Adresse, an Mandant und Benutzer gebunden). NEU `reminder-mail.scheduler.ts`: +4 gebunden je Kandidatenzeile (`reminder.updateMany` als Anspruch, `reminder.findFirst`, `user.findFirst` für die Adresse des Besitzers, `reminder.updateMany` als Freigabe bei Transportfehler; alle über `forTenant(prisma, c.tenantId)` ohne Benutzer) und +1 System (`systemPrisma.reminder.findMany`, die Kandidatenabfrage über alle Mandanten, nur skalarer Select). Vorher: **quick-260929-if2 (Aufgabe 2):** nachgemessen mit der Gate-Schleife: 0/7/0 — +4 gebunden: `update` (`update`), `snooze` (`update`), `remove` (`delete`) und die gemeinsame Besitzprüfung `loadOwn` (`findFirst`, ein Treffer für alle drei; fremde und unbekannte Kennungen sind dort ununterscheidbar 404, D-05). Vorher: **quick-260929-if2 (Aufgabe 1, Tracer):** neu, drei gebundene Rohtreffer in `reminders.service.ts`, nachgemessen mit der Gate-Schleife: 0/3/0 — `list` (`findMany`), `create` (`count` fuer die Grenze von 100 und `create`). Persönliche Erinnerungen je Benutzer, jede Methode bindet mit Mandant UND Benutzer (`forTenant(prisma, tenantId, userId)`). Kein ungebundener Zugriff, kein Systemkontext in diesem Bereich (der E-Mail-Planer folgt in Aufgabe 3). |
| kantine-datev | 0 | 2 | 0 | **quick-261002-fm5:** neu, zwei gebundene Rohtreffer in `kantine-datev.service.ts` (`getSettings` `findUnique`, `saveSettings` `upsert`), nachgemessen mit der Gate-Schleife: 0/2/0. Kein ungebundener Zugriff, kein Systemkontext. |
| handelsware-datev | 0 | 8 | 0 | **quick-261002-fm5:** neu, acht gebundene Rohtreffer über `tenantPrisma` in `handelsware-datev.service.ts` (`handelswareDatevConfig` 2, `handelswareKonto` 6), nachgemessen mit der Gate-Schleife: 0/8/0. Dazu fünf Zugriffe über den Transaktionsparameter `tx` von `withTenantTransaction` (`handelswareDatevConfig` 1, `handelswareKonto` 4), die diese einfache Rohtrefferzählung strukturell nicht sieht (siehe Hinweis zu `groups` oben) — die Bestandsaufnahme unten führt sie. Kein ungebundener Zugriff, kein Systemkontext. |
-| nextcloud-status | 0 | 4 | 0 | **quick-261002-k67 (Aufgabe 1):** neu, vier gebundene Rohtreffer über `tenantPrisma` in `nextcloud-status.service.ts` (`nextcloudInstance`: `listForTenant` `findMany`, `createInstance` `create`, `checkInstance` `findFirst` und `update`), nachgemessen mit der Gate-Schleife: 0/4/0. Kein ungebundener Zugriff, kein Systemkontext. |
-| **Summe** | **61** | **254** | **7** | **quick-261002-k67 (Aufgabe 1):** Gebunden +4 = `nextcloud-status` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 61/254/7, nachgemessen mit der Gate-Schleife. Vorher: **quick-261002-fm5 (Aufgabe 2):** Gebunden +8 = `handelsware-datev` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 61/250/7, nachgemessen mit der Gate-Schleife. Vorher: **quick-261002-fm5 (Aufgabe 1):** Gebunden +2 = `kantine-datev` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 61/242/7, nachgemessen mit der Gate-Schleife. Vorher: **Willkommensmail-Vorlage:** Gebunden +5 = `user` (siehe dortige Zeile), Ungebunden und System unverändert: 61/240/7. Vorher 61/235/7 — **Nachgemessen quick-260929-if2 (Aufgabe 3):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/235/7. Gegenüber der bisherigen Zeile (61/230/6): Gebunden +5 und System +1 = `reminders` (siehe dortige Zeile), Ungebunden unverändert. Vorher: **Nachgemessen quick-260929-if2 (Aufgabe 2):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/230/6. Gegenüber der bisherigen Zeile (61/226/6): Gebunden +4 = `reminders` +4 (siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-if2 (Aufgabe 1):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/226/6. Gegenüber der bisherigen Zeile (61/223/6): Gebunden +3 = `reminders` +3 (neu, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-dzu:** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/223/6. Gegenüber der bisherigen Zeile (61/224/6): Gebunden −1 = `custom-modules` −1 (7→6, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-9wc:** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/224/6. Gegenueber der bisherigen Zeile (61/216/6): Gebunden +8 = `user` +1 (Drift aus quick-260928-ujj, siehe dortige Zeile; gemessen war schon vorher 61/217/6) und `custom-modules` +7 (neu, siehe dortige Zeile), Ungebunden/System unveraendert. Vorher: **quick-260925-bow:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/216/6. Gegenüber der bisherigen Zeile (61/213/6): Gebunden +3 = `user` +3 (die zwei Selbstbedienungswege des „Was ist neu“-Fensters in `user.controller.ts`, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260924-m4n:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/213/6. Gegenüber der bisherigen Zeile (61/208/7): Gebunden +5 = `favorites` +4 (Drift aus quick-260923-lrr nachgeholt) und `dashboard` +1 (Drift +3 nachgeholt, diese Änderung −2; siehe dortige Zeilen), System −1 (`dashboard`, Bootstrap-Umzug der Bilderrahmen-Bilder entfernt). Vorher: **quick-260923-dhh (Aufgabe 5, Endstand):** Gebunden 204→208 (`proxmox` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 4):** Gebunden 201→204 (`proxmox` +3, siehe dortige Zeile), System 6→7 (`proxmox` +1) — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 1):** Gebunden 197→201 (`proxmox` neu, +4, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260923-ad9 (Task 5, Endstand nach Task 2):** Gebunden 193→197 (`dashboard` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-ad9 (Task 1):** Gebunden 190→193 (`dashboard` +3, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260922-hk4:** Gebunden 187→190, System 5→6 (beides `dashboard`, siehe dortige Zeile), Ungebunden unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft |
+| nextcloud-status | 0 | 14 | 1 | **quick-261002-k67 (Aufgabe 2, Endstand):** 4→14 gebunden, 0→1 System — Schreibwege und Planer: `updateInstance` (`findFirst`, `update`), `deleteInstance` (`findFirst`, `delete`), `uploadLogo` (`findFirst`, `update`), `getLogo` (`findFirst`), `removeLogo` (`findFirst`, `update`), `listInstanceIdsForTenant` (`findMany`) bringen zehn weitere gebundene Rohtreffer, je Methode ein Klient. Der eine System-Rohtreffer ist `loadAllInstancesForScheduler()` (`systemPrisma.nextcloudInstance.findMany`, nur `select: { id, tenantId }`, der einzige `forSystem()`-Aufruf des Moduls, Erlaubnisliste in `rls-access-inventory.spec.ts`). Nachgemessen mit der Gate-Schleife: 0/14/1. Vorher: **quick-261002-k67 (Aufgabe 1):** neu, vier gebundene Rohtreffer in `nextcloud-status.service.ts` (`listForTenant` `findMany`, `createInstance` `create`, `checkInstance` `findFirst` und `update`). |
+| **Summe** | **61** | **264** | **8** | **quick-261002-k67 (Aufgabe 2):** Gebunden +10 und System +1 = `nextcloud-status` (siehe dortige Zeile), Ungebunden unverändert: 61/264/8, nachgemessen mit der Gate-Schleife. Vorher: **quick-261002-k67 (Aufgabe 1):** Gebunden +4 = `nextcloud-status` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 61/254/7, nachgemessen mit der Gate-Schleife. Vorher: **quick-261002-fm5 (Aufgabe 2):** Gebunden +8 = `handelsware-datev` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 61/250/7, nachgemessen mit der Gate-Schleife. Vorher: **quick-261002-fm5 (Aufgabe 1):** Gebunden +2 = `kantine-datev` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 61/242/7, nachgemessen mit der Gate-Schleife. Vorher: **Willkommensmail-Vorlage:** Gebunden +5 = `user` (siehe dortige Zeile), Ungebunden und System unverändert: 61/240/7. Vorher 61/235/7 — **Nachgemessen quick-260929-if2 (Aufgabe 3):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/235/7. Gegenüber der bisherigen Zeile (61/230/6): Gebunden +5 und System +1 = `reminders` (siehe dortige Zeile), Ungebunden unverändert. Vorher: **Nachgemessen quick-260929-if2 (Aufgabe 2):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/230/6. Gegenüber der bisherigen Zeile (61/226/6): Gebunden +4 = `reminders` +4 (siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-if2 (Aufgabe 1):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/226/6. Gegenüber der bisherigen Zeile (61/223/6): Gebunden +3 = `reminders` +3 (neu, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-dzu:** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/223/6. Gegenüber der bisherigen Zeile (61/224/6): Gebunden −1 = `custom-modules` −1 (7→6, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-9wc:** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/224/6. Gegenueber der bisherigen Zeile (61/216/6): Gebunden +8 = `user` +1 (Drift aus quick-260928-ujj, siehe dortige Zeile; gemessen war schon vorher 61/217/6) und `custom-modules` +7 (neu, siehe dortige Zeile), Ungebunden/System unveraendert. Vorher: **quick-260925-bow:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/216/6. Gegenüber der bisherigen Zeile (61/213/6): Gebunden +3 = `user` +3 (die zwei Selbstbedienungswege des „Was ist neu“-Fensters in `user.controller.ts`, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260924-m4n:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/213/6. Gegenüber der bisherigen Zeile (61/208/7): Gebunden +5 = `favorites` +4 (Drift aus quick-260923-lrr nachgeholt) und `dashboard` +1 (Drift +3 nachgeholt, diese Änderung −2; siehe dortige Zeilen), System −1 (`dashboard`, Bootstrap-Umzug der Bilderrahmen-Bilder entfernt). Vorher: **quick-260923-dhh (Aufgabe 5, Endstand):** Gebunden 204→208 (`proxmox` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 4):** Gebunden 201→204 (`proxmox` +3, siehe dortige Zeile), System 6→7 (`proxmox` +1) — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 1):** Gebunden 197→201 (`proxmox` neu, +4, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260923-ad9 (Task 5, Endstand nach Task 2):** Gebunden 193→197 (`dashboard` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-ad9 (Task 1):** Gebunden 190→193 (`dashboard` +3, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260922-hk4:** Gebunden 187→190, System 5→6 (beides `dashboard`, siehe dortige Zeile), Ungebunden unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft |
## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 89 Paare)
@@ -401,6 +401,9 @@ quick-261002-k67 (Aufgabe 1): +1 `muss-mandantengebunden` (`nextcloud-status.ser
93 Paare, davon 53 `muss-mandantengebunden`, 22 `keine-mandantengebundene-tabelle`, 16 `beides`,
2 `bewusst-uebergreifend` — nachgezaehlt mit `grep -cE '^\| apps/api/src/'` gegen die Bestandsaufnahme.
+quick-261002-k67 (Aufgabe 2): Paarzahl unverändert (93); der Stand des Paars `nextcloud-status.service.ts`/`nextcloudInstance`
+wechselt von `gebunden` auf `system-gebunden` (siehe oben).
+
## Der Hintergrunddienst als Falle — sechs Fälle
Ein Planer, der über alle Mandanten iteriert, liest zu Recht übergreifend —
@@ -684,6 +687,16 @@ geloest durch die drei SECURITY-DEFINER-Funktionen, nicht durch die Bauform
Regel sähe der Planer nach dem Scharfschalten keine Erinnerung und
verstummte (zu-wenig-statt-zu-viel). `FORSYSTEM_ALLOWED_CALL_SITES` pinnt
genau einen Aufruf in dieser Datei (neue Summe: 6 Dateien, 7 Aufrufe).
+- **Nachtrag quick-261002-k67 — neuer Fall: `nextcloud-status-scheduler.service.ts`
+ (stündliche Cloud-Prüfung).** EIN globaler Auftrag `nextcloud-status-poll`
+ (`0 * * * *`), beim Start ohne Datenbankzugriff registriert. Jeder Durchlauf
+ liest über `forSystem()` (`NextcloudStatusService.loadAllInstancesForScheduler`,
+ `nextcloudInstance.findMany`, `system_read_policy ... FOR SELECT` auf
+ "NextcloudInstance", Migration 20261002150000) nur `id` und `tenantId` aller
+ Clouds, prüft dann jede Cloud über `checkInstance(tenantId, id)` an ihren
+ eigenen Mandanten gebunden (höchstens vier gleichzeitig) und schreibt nie im
+ Systemkontext. `FORSYSTEM_ALLOWED_CALL_SITES` pinnt genau einen Aufruf in
+ `nextcloud-status.service.ts` (neue Summe: 7 Dateien, 8 Aufrufe).
## Bestandsaufnahme
@@ -842,7 +855,7 @@ werden.
| apps/api/src/kantine-datev/kantine-datev.service.ts | kantineDatevConfig | muss-mandantengebunden | gebunden | **quick-261002-fm5:** neu — die drei Nummern der Kantinenabrechnung (Beraternummer, Mandantennummer, Lohnart), eine Zeile je Mandant (Singleton, Vorbild `DkvModuleConfig`). `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20261002120000) — Einstellungen des Mandanten, nicht persönliche Daten eines Benutzers. Bewusst KEINE `system_read_policy`: es gibt keinen Hintergrunddienst, der diese Einstellungen über alle Mandanten liest. Zwei mandantengebundene Rohtreffer, je Methode ein eigener Klient (`const tenantPrisma = forTenant(this.prisma, tenantId)`): `getSettings` (`findUnique`), `saveSettings` (`upsert`). Die hochgeladene Kantinen-CSV (Namen, Personalnummern) berührt die Datenbank nie. |
| apps/api/src/handelsware-datev/handelsware-datev.service.ts | handelswareDatevConfig | muss-mandantengebunden | gebunden | **quick-261002-fm5:** neu — Einstellungen der Handelsware (Standard-Erlöskonto, Startwert Gegenkonto), eine Zeile je Mandant (Singleton, Vorbild `DkvModuleConfig`). `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20261002130000) — Einstellungen des Mandanten, nicht persönliche Daten eines Benutzers. Bewusst KEINE `system_read_policy`: kein Hintergrunddienst. Zwei Rohtreffer über `const tenantPrisma = forTenant(this.prisma, tenantId)` (`getSettings` `findUnique`, `saveSettings` `upsert`) und einer über den Transaktionsparameter von `withTenantTransaction` (`export` liest die Einstellungen in derselben Transaktion wie die Kontenliste, `tx.handelswareDatevConfig.findUnique`). |
| apps/api/src/handelsware-datev/handelsware-datev.service.ts | handelswareKonto | muss-mandantengebunden | gebunden | **quick-261002-fm5:** neu — Kontenliste der Handelsware (Produktname → Gegenkonto, Erlöskonto), mehrere Zeilen je Mandant, Name je Mandant eindeutig. `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20261002130000, Form aus `ProxmoxServer`), keine `system_read_policy`. Sechs Rohtreffer über `tenantPrisma` (`listAccounts` `findMany`, `createAccount` `create`, `updateAccount` `findFirst` UND `update`, `deleteAccount` `findFirst` UND `delete`) und vier über den Transaktionsparameter von `withTenantTransaction` (`importAccountsCsv` `deleteMany` UND `createMany` als EINE Transaktion; `export` `findMany` UND `createMany` — berechnet die Zuordnung neu und speichert neue Konten in derselben Transaktion, in der die Datei entsteht). `updateAccount`/`deleteAccount` prüfen die Kennung zusätzlich mit `where: { id, tenantId }` und antworten mit 404 (zweites Netz, solange der RLS-Schalter aus ist). |
-| apps/api/src/nextcloud-status/nextcloud-status.service.ts | nextcloudInstance | muss-mandantengebunden | gebunden | **quick-261002-k67:** neu — die vom Verwalter eingetragenen Nextcloud-Clouds der Kunden (Kundenname, Adresse, optionales Logo) samt zuletzt ermitteltem Zustand (Erreichbarkeit, Versionstext, Fehlerart) auf derselben Zeile. `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20261002150000, Form aus `ProxmoxServer`) — gemeinsame Daten der Organisation, nicht persönliche Daten eines Benutzers. Zusätzlich eine `system_read_policy … FOR SELECT` für den stündlichen Hintergrunddienst (Aufgabe 2). Vier mandantengebundene Rohtreffer, je Methode ein eigener Klient (`const tenantPrisma = forTenant(this.prisma, tenantId)`): `listForTenant` (`findMany`), `createInstance` (`create`), `checkInstance` (`findFirst` und `update`, derselbe Klient). Alle Abfragen wählen die Logo-Bytes per `select` gar nicht erst aus (T-k67-07); über `where: { id, tenantId }` liefert eine fremde Kennung 404. |
+| apps/api/src/nextcloud-status/nextcloud-status.service.ts | nextcloudInstance | muss-mandantengebunden | system-gebunden | **quick-261002-k67:** neu — die vom Verwalter eingetragenen Nextcloud-Clouds der Kunden (Kundenname, Adresse, optionales Logo) samt zuletzt ermitteltem Zustand (Erreichbarkeit, Versionstext, Fehlerart) auf derselben Zeile. `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20261002150000, Form aus `ProxmoxServer`) — gemeinsame Daten der Organisation, nicht persönliche Daten eines Benutzers. Stand `system-gebunden` — NICHT weil ein Anfrageweg aufgeweicht wurde, sondern weil EIN Startpfad dazugekommen ist: `loadAllInstancesForScheduler()` liest für den stündlichen Planer `const systemPrisma = forSystem(this.prisma);` (ein Aufruf, Erlaubnisliste in `rls-access-inventory.spec.ts`; Leserecht über `system_read_policy … FOR SELECT` auf "NextcloudInstance", Migration 20261002150000) nur Kennung und Mandant ALLER Clouds (`select: { id, tenantId }`, nie Adresse oder Logo-Bytes). Geprüft und geschrieben wird danach je Cloud mandantengebunden (`checkInstance`, `where: { id, tenantId }`). Vierzehn mandantengebundene Rohtreffer, je Methode ein eigener Klient (`const tenantPrisma = forTenant(this.prisma, tenantId)`): `listForTenant`, `createInstance`, `checkInstance` (zwei), `updateInstance` (zwei), `deleteInstance` (zwei), `uploadLogo` (zwei), `getLogo`, `removeLogo` (zwei), `listInstanceIdsForTenant`; dazu der eine System-Rohtreffer. Alle Abfragen außer `getLogo` wählen die Logo-Bytes per `select` gar nicht erst aus (T-k67-07); über `where: { id, tenantId }` liefert eine fremde Kennung 404. |
## Was diese Etappe NICHT entscheidet