feat(nextcloud-status): Modul mit Statusabruf, Versionsbewertung und Kachelansicht

- NextcloudInstance mit Zeilenschutz, Migration 20261002150000
- Bewertung (Ampel) als reine Funktion, status.php-Abruf mit Grenzen, endoflife.date-Zwischenspeicher
- API: Liste mit Bewertung, Anlegen und Einzelprüfung nur mit Verwalten
- Modulseite mit Kacheln, Registrierung in Seitenleiste, Marktplatz und Symbolen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-02 14:49:02 +02:00
parent 7188733f70
commit 5ef7b0c6cc
31 changed files with 2513 additions and 2 deletions
@@ -0,0 +1,181 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import type { CreateNextcloudInstanceDto } from './dto/nextcloud-instance.dto';
import {
type NextcloudRating,
type NextcloudReference,
newestVersion,
rateNextcloud,
} from './nextcloud-rating';
import { NextcloudReleaseService } from './nextcloud-release.service';
import { fetchNextcloudStatus, normalizeCloudUrl } from './nextcloud-status-fetch';
/**
* Alle Spalten ausser den Logo-Bytes (T-k67-07): Listen- und
* Pruefabfragen holen `logoData` nie aus der Datenbank, nur der
* Logo-Abruf. `logoMime` ist genau dann gesetzt, wenn ein Upload vorliegt.
*/
export const PUBLIC_SELECT = {
id: true,
tenantId: true,
customerName: true,
baseUrl: true,
logoUrl: true,
logoMime: true,
logoVersion: true,
lastCheckedAt: true,
reachable: true,
maintenance: true,
needsDbUpgrade: true,
versionString: true,
edition: true,
errorKind: true,
errorDetail: true,
} as const;
type PublicRow = {
id: string;
customerName: string;
baseUrl: string;
logoUrl: string | null;
logoMime: string | null;
logoVersion: number;
lastCheckedAt: Date | null;
reachable: boolean | null;
maintenance: boolean | null;
needsDbUpgrade: boolean | null;
versionString: string | null;
edition: string | null;
errorKind: string | null;
errorDetail: string | null;
};
export interface NextcloudInstanceView {
id: string;
customerName: string;
baseUrl: string;
logoUrl: string | null;
hasUploadedLogo: boolean;
logoVersion: number;
status: {
checkedAt: string | null;
reachable: boolean | null;
maintenance: boolean | null;
needsDbUpgrade: boolean | null;
versionString: string | null;
edition: string | null;
errorKind: string | null;
errorDetail: string | null;
};
rating: NextcloudRating;
}
export interface NextcloudListView {
instances: NextcloudInstanceView[];
reference: { newestVersion: string | null; fetchedAt: string | null };
}
const INVALID_URL_MESSAGE = 'Bitte geben Sie eine gültige Adresse mit http:// oder https:// ein.';
@Injectable()
export class NextcloudStatusService {
constructor(
private readonly prisma: PrismaService,
private readonly release: NextcloudReleaseService,
) {}
private toView(row: PublicRow, reference: NextcloudReference | null): NextcloudInstanceView {
const status = {
checkedAt: row.lastCheckedAt ? row.lastCheckedAt.toISOString() : null,
reachable: row.reachable,
maintenance: row.maintenance,
needsDbUpgrade: row.needsDbUpgrade,
versionString: row.versionString,
edition: row.edition,
errorKind: row.errorKind,
errorDetail: row.errorDetail,
};
return {
id: row.id,
customerName: row.customerName,
baseUrl: row.baseUrl,
logoUrl: row.logoUrl,
hasUploadedLogo: row.logoMime !== null,
logoVersion: row.logoVersion,
status,
rating: rateNextcloud(status, reference, new Date()),
};
}
/** Alle Clouds des Mandanten samt Bewertung zum Lesezeitpunkt (D-B). */
async listForTenant(tenantId: string): Promise<NextcloudListView> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const [rows, reference] = await Promise.all([
tenantPrisma.nextcloudInstance.findMany({
where: { tenantId },
orderBy: { customerName: 'asc' },
select: PUBLIC_SELECT,
}),
this.release.getReference(),
]);
return {
instances: rows.map((row) => this.toView(row as PublicRow, reference)),
reference: {
newestVersion: newestVersion(reference),
fetchedAt: reference?.fetchedAt ?? null,
},
};
}
/** Legt eine Cloud an und fuehrt sofort die erste Pruefung aus. */
async createInstance(
tenantId: string,
dto: CreateNextcloudInstanceDto,
): Promise<NextcloudInstanceView> {
const baseUrl = normalizeCloudUrl(dto.baseUrl);
if (!baseUrl) throw new BadRequestException(INVALID_URL_MESSAGE);
const tenantPrisma = forTenant(this.prisma, tenantId);
const created = await tenantPrisma.nextcloudInstance.create({
data: {
tenantId,
customerName: dto.customerName.trim(),
baseUrl,
logoUrl: dto.logoUrl ? dto.logoUrl : null,
},
select: { id: true },
});
return this.checkInstance(tenantId, created.id);
}
/**
* Prueft eine Cloud (nur `status.php`, siehe `fetchNextcloudStatus`) und
* schreibt das Ergebnis an die Zeile. Fremde oder unbekannte Kennung: 404.
*/
async checkInstance(tenantId: string, id: string): 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 result = await fetchNextcloudStatus(existing.baseUrl);
const updated = await tenantPrisma.nextcloudInstance.update({
where: { id },
data: {
reachable: result.reachable,
maintenance: result.maintenance,
needsDbUpgrade: result.needsDbUpgrade,
versionString: result.versionString,
edition: result.edition,
productName: result.productName,
errorKind: result.errorKind,
errorDetail: result.errorDetail,
lastCheckedAt: new Date(),
},
select: PUBLIC_SELECT,
});
return this.toView(updated as PublicRow, await this.release.getReference());
}
}