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:
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user