feat(nextcloud-status): Clouds verwalten, Logos, stündliche Prüfung und Sortierung

- Ändern, Entfernen, Logo-Upload und -Abruf, Sammelprüfung nur mit Verwalten
- Stündlicher Auftrag beim Start registriert, Prüfung je Cloud an ihren Mandanten gebunden
- Sortierung nach Kundenname, Status, Version und Support-Ende, je Benutzer gemerkt
- Formular zum Anlegen und Bearbeiten, Zugriffsinventar angepasst

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-02 14:54:58 +02:00
parent 5ef7b0c6cc
commit 6698ef1a92
23 changed files with 2061 additions and 30 deletions
@@ -1,7 +1,12 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import type { UploadedFileLike } from '../auth/types/auth-user';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import type { CreateNextcloudInstanceDto } from './dto/nextcloud-instance.dto';
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
import type {
CreateNextcloudInstanceDto,
UpdateNextcloudInstanceDto,
} from './dto/nextcloud-instance.dto';
import { checkLogoUpload } from './nextcloud-logo-rules';
import {
type NextcloudRating,
type NextcloudReference,
@@ -76,10 +81,34 @@ export interface NextcloudListView {
reference: { newestVersion: string | null; fetchedAt: string | null };
}
/** Hoechstzahl gleichzeitiger Pruefungen (Sammelpruefung und stuendlicher Durchlauf). */
export const CHECK_CONCURRENCY = 4;
/**
* Arbeitet `items` mit hoechstens `limit` gleichzeitig ab. `fn` darf werfen —
* der Aufrufer faengt je Eintrag selbst, ein Fehler stoppt die anderen nicht.
*/
export async function runWithConcurrency<T>(
items: T[],
limit: number,
fn: (item: T) => Promise<void>,
): Promise<void> {
let next = 0;
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (next < items.length) {
const item = items[next++];
await fn(item);
}
});
await Promise.all(workers);
}
const INVALID_URL_MESSAGE = 'Bitte geben Sie eine gültige Adresse mit http:// oder https:// ein.';
@Injectable()
export class NextcloudStatusService {
private readonly logger = new Logger(NextcloudStatusService.name);
constructor(
private readonly prisma: PrismaService,
private readonly release: NextcloudReleaseService,
@@ -178,4 +207,172 @@ export class NextcloudStatusService {
});
return this.toView(updated as PublicRow, await this.release.getReference());
}
/**
* Aendert Name, Adresse und/oder Logo-Adresse. Eine neue Adresse wird
* normalisiert und sofort neu geprueft, eine unveraenderte nicht. Eine
* nicht leere Logo-Adresse ersetzt ein hochgeladenes Logo, eine leere
* entfernt nur die Adresse (D-A).
*/
async updateInstance(
tenantId: string,
id: string,
dto: UpdateNextcloudInstanceDto,
): Promise<NextcloudInstanceView> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const existing = await tenantPrisma.nextcloudInstance.findFirst({
where: { id, tenantId },
select: { id: true, baseUrl: true },
});
if (!existing) throw new NotFoundException('Cloud nicht gefunden');
const data: Record<string, unknown> = {};
if (dto.customerName !== undefined) data.customerName = dto.customerName.trim();
let urlChanged = false;
if (dto.baseUrl !== undefined) {
const baseUrl = normalizeCloudUrl(dto.baseUrl);
if (!baseUrl) throw new BadRequestException(INVALID_URL_MESSAGE);
if (baseUrl !== existing.baseUrl) {
data.baseUrl = baseUrl;
urlChanged = true;
}
}
if (dto.logoUrl !== undefined) {
if (dto.logoUrl) {
data.logoUrl = dto.logoUrl;
data.logoData = null;
data.logoMime = null;
} else {
data.logoUrl = null;
}
data.logoVersion = { increment: 1 };
}
const updated = await tenantPrisma.nextcloudInstance.update({
where: { id },
data,
select: PUBLIC_SELECT,
});
if (urlChanged) return this.checkInstance(tenantId, id);
return this.toView(updated as PublicRow, await this.release.getReference());
}
/** Loescht eine Cloud. Fremde oder unbekannte Kennung: 404. */
async deleteInstance(tenantId: string, id: string): Promise<boolean> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const existing = await tenantPrisma.nextcloudInstance.findFirst({
where: { id, tenantId },
select: { id: true },
});
if (!existing) throw new NotFoundException('Cloud nicht gefunden');
await tenantPrisma.nextcloudInstance.delete({ where: { id } });
return true;
}
/**
* Speichert ein hochgeladenes Logo. Der Typ kommt aus den Magic Bytes
* (`checkLogoUpload`), nie aus dem Mimetype des Browsers; eine vorhandene
* Logo-Adresse wird entfernt (Upload und Adresse schliessen sich aus).
*/
async uploadLogo(
tenantId: string,
id: string,
file: UploadedFileLike | undefined,
): Promise<NextcloudInstanceView> {
const mime = file ? checkLogoUpload(file.buffer) : null;
if (!file || !mime) {
throw new BadRequestException(
'Bitte laden Sie ein Bild im Format PNG, JPEG, GIF oder WebP bis 1 MB hoch.',
);
}
const tenantPrisma = forTenant(this.prisma, tenantId);
const existing = await tenantPrisma.nextcloudInstance.findFirst({
where: { id, tenantId },
select: { id: true },
});
if (!existing) throw new NotFoundException('Cloud nicht gefunden');
const updated = await tenantPrisma.nextcloudInstance.update({
where: { id },
data: {
logoData: new Uint8Array(file.buffer),
logoMime: mime,
logoUrl: null,
logoVersion: { increment: 1 },
},
select: PUBLIC_SELECT,
});
return this.toView(updated as PublicRow, await this.release.getReference());
}
/** Liefert die Bytes des hochgeladenen Logos — die einzige Abfrage, die `logoData` auswaehlt. */
async getLogo(tenantId: string, id: string): Promise<{ data: Buffer; mime: string }> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const row = await tenantPrisma.nextcloudInstance.findFirst({
where: { id, tenantId },
select: { logoData: true, logoMime: true },
});
if (!row?.logoData || !row.logoMime) throw new NotFoundException('Kein Logo vorhanden');
return { data: Buffer.from(row.logoData), mime: row.logoMime };
}
/** Entfernt ein hochgeladenes Logo (die Kachel faellt auf Initialen zurueck). */
async removeLogo(tenantId: string, id: string): Promise<NextcloudInstanceView> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const existing = await tenantPrisma.nextcloudInstance.findFirst({
where: { id, tenantId },
select: { id: true },
});
if (!existing) throw new NotFoundException('Cloud nicht gefunden');
const updated = await tenantPrisma.nextcloudInstance.update({
where: { id },
data: { logoData: null, logoMime: null, logoVersion: { increment: 1 } },
select: PUBLIC_SELECT,
});
return this.toView(updated as PublicRow, await this.release.getReference());
}
/** Kennungen aller Clouds des Mandanten. */
async listInstanceIdsForTenant(tenantId: string): Promise<string[]> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const rows = await tenantPrisma.nextcloudInstance.findMany({
where: { tenantId },
select: { id: true },
});
return rows.map((r: { id: string }) => r.id);
}
/**
* "Jetzt pruefen" fuer die ganze Liste: alle Clouds des Mandanten mit
* hoechstens vier gleichzeitig, danach die frische Liste. Eine fehlerhafte
* Cloud stoppt die anderen nicht.
*/
async checkAllForTenant(tenantId: string): Promise<NextcloudListView> {
const ids = await this.listInstanceIdsForTenant(tenantId);
await runWithConcurrency(ids, CHECK_CONCURRENCY, async (id) => {
try {
await this.checkInstance(tenantId, id);
} catch (err) {
this.logger.warn(
`Nextcloud-Pruefung fehlgeschlagen (Cloud ${id}): ${(err as Error).message}`,
);
}
});
return this.listForTenant(tenantId);
}
/**
* Startpfad des stuendlichen Planers — der EINZIGE Systemkontext-Aufruf
* dieses Moduls (`FORSYSTEM_ALLOWED_CALL_SITES`, `rls-access-inventory.spec.ts`;
* Leserecht ueber `system_read_policy ... FOR SELECT` der Migration
* 20261002150000): nur Kennung und Mandant ALLER Clouds, nie Logo-Bytes oder
* Adressen. Geprueft und geschrieben wird danach je Cloud an ihren eigenen
* Mandanten gebunden (`checkInstance`).
*/
async loadAllInstancesForScheduler(): Promise<{ id: string; tenantId: string }[]> {
const systemPrisma = forSystem(this.prisma);
return systemPrisma.nextcloudInstance.findMany({
select: { id: true, tenantId: true },
});
}
}