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