feat(quick-261008-w5w): Modul-Changelog Durchstich mit Nextcloud-Status

- Typen, Register, Route GET /modules/changelog/:slug, Waechter-Test
- Seed liest die Version per latestVersion aus dem Changelog
- Marktplatz-Detailseite zeigt den Abschnitt Aenderungen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-08 23:25:02 +02:00
parent 1d339903e0
commit 79210d7966
12 changed files with 674 additions and 2 deletions
@@ -0,0 +1,20 @@
import { NEXTCLOUD_STATUS_CHANGELOG } from '../nextcloud-status/nextcloud-status.changelog';
import type { ModuleChangelog } from './module-changelog';
/**
* Alle Modul-Changelogs nach Modul-Slug (quick-261008-w5w), alphabetisch.
* Ein neues Modul trägt seinen Changelog hier ein; der Wächter-Test
* `module-changelog.spec.ts` prüft Vollständigkeit und Versionsgleichheit.
*/
export const MODULE_CHANGELOGS: ReadonlyMap<string, ModuleChangelog> = new Map<
string,
ModuleChangelog
>([['nextcloud-status', NEXTCLOUD_STATUS_CHANGELOG]]);
/**
* Map statt Objekt-Index, damit `__proto__`, `constructor` und Co. nicht in die
* Prototypenkette greifen (T-261008-w5w-02). Unbekannte Kennung → leere Liste.
*/
export function getModuleChangelog(slug: string): ModuleChangelog {
return MODULE_CHANGELOGS.get(slug) ?? [];
}
@@ -0,0 +1,180 @@
import { readdirSync, statSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import { compareSemver, latestVersion } from './module-changelog';
import { MODULE_CHANGELOGS, getModuleChangelog } from './module-changelog.registry';
import { ModuleRegistryController } from './module-registry.controller';
import type { ModuleRegistryService } from './module-registry.service';
/**
* Wächter-Test (quick-261008-w5w): Die Modulversion steht nur im Changelog; ein
* Seed ohne Changelog, eine Seed-Version abseits des neuesten Eintrags oder ein
* fehlerhafter Eintrag lassen diesen Test fehlschlagen.
*/
const SRC = resolve(__dirname, '..');
// Ersatzschreibungen in deutschen Texten (ganze Wörter, ohne Groß-/Kleinschreibung).
const REPLACEMENT_WORDS = [
'fuer', 'ueber', 'koennen', 'moechten', 'muessen', 'Aenderung', 'Aenderungen', 'geaendert',
'moeglich', 'zurueck', 'loeschen', 'geloescht', 'pruefen', 'geprueft', 'Pruefung', 'waehlen',
'ausgewaehlt', 'Schluessel', 'groesser', 'Groesse', 'schliessen', 'oeffnen', 'geoeffnet',
];
const REPLACEMENT_RE = new RegExp(`\\b(${REPLACEMENT_WORDS.join('|')})\\b`, 'i');
// Mandanten-/Lizenzbegriffe: ganze Wörter bzw. Wortanfänge („Mandantennummer“ löst nicht aus).
const FORBIDDEN_RE = /\b(Mandant|Mandanten|tenant|tenants)\b|\b(Lizenz|licens|licenc)/i;
function isRealDate(value: string): boolean {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
const d = new Date(`${value}T00:00:00Z`);
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === value;
}
interface Manifest {
slug: string;
version: string;
}
async function collectSeedManifests(): Promise<{ files: string[]; manifests: Manifest[] }> {
const files: string[] = [];
for (const dir of readdirSync(SRC)) {
const full = join(SRC, dir);
if (!statSync(full).isDirectory()) continue;
for (const file of readdirSync(full)) {
if (file.endsWith('.seed.ts')) files.push(join(full, file));
}
}
const manifests: Manifest[] = [];
const registry = {
seedModule: async (manifest: Manifest) => {
manifests.push(manifest);
},
} as unknown as ModuleRegistryService;
for (const file of files) {
const mod = (await import(file)) as Record<string, unknown>;
for (const [name, fn] of Object.entries(mod)) {
if (/^seed\w*Module$/.test(name) && typeof fn === 'function') {
await (fn as (r: ModuleRegistryService) => Promise<void>)(registry);
}
}
}
return { files, manifests };
}
describe('compareSemver / latestVersion', () => {
it('vergleicht numerisch', () => {
expect(compareSemver('1.10.0', '1.9.2')).toBe(1);
expect(compareSemver('2.0.0', '1.99.99')).toBe(1);
expect(compareSemver('1.9.2', '1.10.0')).toBe(-1);
expect(compareSemver('1.2.3', '1.2.3')).toBe(0);
});
it('latestVersion liefert den ersten Eintrag, leer wirft verständlich', () => {
expect(latestVersion([{ version: '1.2.0', date: '2026-10-08', changes: [] }])).toBe('1.2.0');
expect(() => latestVersion([])).toThrow(/Changelog/);
});
});
describe('getModuleChangelog', () => {
it('liefert das Array des Registers', () => {
expect(getModuleChangelog('nextcloud-status')).toBe(MODULE_CHANGELOGS.get('nextcloud-status'));
expect(getModuleChangelog('nextcloud-status').length).toBeGreaterThan(0);
});
it.each(['does-not-exist', '__proto__', 'constructor', 'toString', 'hasOwnProperty'])(
'liefert für %s eine leere Liste',
(slug) => {
expect(getModuleChangelog(slug)).toEqual([]);
},
);
});
describe('ModuleRegistryController.getChangelog', () => {
const controller = new ModuleRegistryController({} as any, {} as any, {} as any);
it('gibt das Ergebnis des Registers unverändert zurück', () => {
expect(controller.getChangelog('nextcloud-status')).toBe(
MODULE_CHANGELOGS.get('nextcloud-status'),
);
expect(controller.getChangelog('gibt-es-nicht')).toEqual([]);
});
});
describe('Format aller Modul-Changelogs', () => {
for (const [slug, changelog] of MODULE_CHANGELOGS) {
describe(slug, () => {
it('ist nicht leer', () => {
expect(changelog.length).toBeGreaterThan(0);
});
it('hat gültige Versionen, strikt absteigend', () => {
for (const release of changelog) {
expect(release.version, `${slug}: Version`).toMatch(/^\d+\.\d+\.\d+$/);
}
for (let i = 1; i < changelog.length; i += 1) {
expect(
compareSemver(changelog[i - 1].version, changelog[i].version),
`${slug}: ${changelog[i - 1].version} muss größer sein als ${changelog[i].version}`,
).toBe(1);
}
});
it('hat echte Kalenderdaten, neueste oben', () => {
for (const release of changelog) {
expect(isRealDate(release.date), `${slug} ${release.version}: Datum ${release.date}`).toBe(true);
}
for (let i = 1; i < changelog.length; i += 1) {
expect(
changelog[i - 1].date >= changelog[i].date,
`${slug}: Daten dürfen nicht aufsteigen (${changelog[i - 1].date} / ${changelog[i].date})`,
).toBe(true);
}
});
it('hat je Version mindestens einen Eintrag mit gültiger Art und de/en-Text', () => {
for (const release of changelog) {
expect(release.changes.length, `${slug} ${release.version}: Einträge`).toBeGreaterThan(0);
for (const item of release.changes) {
expect(['new', 'changed', 'fixed']).toContain(item.kind);
expect(item.de.trim(), `${slug} ${release.version}: de leer`).not.toBe('');
expect(item.en.trim(), `${slug} ${release.version}: en leer`).not.toBe('');
}
}
});
it('hat keine Ersatzschreibungen in den deutschen Texten', () => {
for (const release of changelog) {
for (const item of release.changes) {
expect(item.de, `${slug} ${release.version}`).not.toMatch(REPLACEMENT_RE);
}
}
});
it('enthält keine Mandanten- oder Lizenzbegriffe', () => {
for (const release of changelog) {
for (const item of release.changes) {
expect(item.de, `${slug} ${release.version} de`).not.toMatch(FORBIDDEN_RE);
expect(item.en, `${slug} ${release.version} en`).not.toMatch(FORBIDDEN_RE);
}
}
});
});
}
});
describe('Seeds und Changelogs gehören zusammen', () => {
it('findet die Seed-Dateien und liest Manifeste', async () => {
const { files, manifests } = await collectSeedManifests();
expect(files.length).toBeGreaterThanOrEqual(10);
expect(manifests.length).toBeGreaterThanOrEqual(10);
});
it('Seed-Version entspricht dem neuesten Changelog-Eintrag', async () => {
const { manifests } = await collectSeedManifests();
for (const manifest of manifests) {
const changelog = MODULE_CHANGELOGS.get(manifest.slug);
if (!changelog) continue; // Vollständigkeit prüft der nächste Test
expect(manifest.version, `${manifest.slug}: Seed-Version`).toBe(changelog[0].version);
}
});
});
@@ -0,0 +1,51 @@
/**
* Modul-Changelog (quick-261008-w5w).
*
* Eine Quelle der Wahrheit für die Versionsnummer eines Moduls: Jedes Modul hat
* neben seiner Seed-Datei eine `<name>.changelog.ts` mit den Änderungen,
* neueste Version zuerst. Die Seed-Datei liest ihre Version ausschließlich per
* `latestVersion(...)` von dort; der Marktplatz zeigt die Einträge über
* `GET /modules/changelog/:slug`. Der Wächter-Test `module-changelog.spec.ts`
* schlägt fehl, wenn Seed und Changelog auseinanderlaufen.
*/
export type ModuleChangeKind = 'new' | 'changed' | 'fixed';
export interface ModuleChangelogItem {
kind: ModuleChangeKind;
de: string;
en: string;
}
export interface ModuleChangelogRelease {
/** Modulversion im Muster Haupt.Neben.Fehler, z. B. `1.2.0`. */
version: string;
/** Datum der Tessera-Version (oder des Eintrags), Format JJJJ-MM-TT. */
date: string;
changes: readonly ModuleChangelogItem[];
}
/** Neueste Version zuerst. */
export type ModuleChangelog = readonly ModuleChangelogRelease[];
/** Vergleicht zwei Versionen numerisch je Teil (1.10.0 ist größer als 1.9.2). */
export function compareSemver(a: string, b: string): number {
const pa = a.split('.').map(Number);
const pb = b.split('.').map(Number);
const len = Math.max(pa.length, pb.length);
for (let i = 0; i < len; i += 1) {
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
if (diff !== 0) return diff > 0 ? 1 : -1;
}
return 0;
}
/** Version des neuesten Eintrags; ein leerer Changelog ist ein Programmierfehler. */
export function latestVersion(changelog: ModuleChangelog): string {
if (changelog.length === 0) {
throw new Error(
'Der Modul-Changelog ist leer: Mindestens ein Eintrag (neueste Version zuerst) in der Datei <modul>.changelog.ts ist nötig.',
);
}
return changelog[0].version;
}
@@ -13,6 +13,7 @@ import { Roles } from '../auth/decorators/roles.decorator';
import { RolesGuard } from '../auth/guards/roles.guard';
import { ModuleCategoriesService } from '../module-categories/module-categories.service';
import { ModuleAccessService } from './module-access.service';
import { getModuleChangelog } from './module-changelog.registry';
import { ModuleRegistryService } from './module-registry.service';
/**
@@ -22,6 +23,10 @@ import { ModuleRegistryService } from './module-registry.service';
* - GET /modules/active — list modules accessible to the requesting user
* (Aktivierung UND Grant/Rolle, via ModuleAccessService — D-01: dieselbe
* Auflösung wie ModuleGuard)
* - GET /modules/changelog/:slug — Modul-Changelog aus dem Code (neueste Version
* zuerst; unbekannte Kennung → leere Liste). Das statische Segment `changelog`
* steht vorn, weil die Modul-Controller unter `modules/<slug>` eigene
* `:id`-Routen haben und `/modules/:slug/changelog` sonst verschattet würde.
* - POST /modules/:moduleId/activate — activate a module (ADMIN/SUPER_ADMIN)
* - POST /modules/:moduleId/deactivate — deactivate a module (ADMIN/SUPER_ADMIN)
*
@@ -106,6 +111,17 @@ export class ModuleRegistryController {
}));
}
/**
* GET /modules/changelog/:slug
* Liefert den Changelog eines eingebauten Moduls (quick-261008-w5w). Statische
* Produkttexte, daher wie der Katalog ohne @Roles für jeden angemeldeten
* Benutzer (T-03-03). Unbekannte und eigene Module → leere Liste.
*/
@Get('changelog/:slug')
getChangelog(@Param('slug') slug: string) {
return getModuleChangelog(slug);
}
/**
* POST /modules/:moduleId/activate
* Activates a module for the current tenant.
@@ -0,0 +1,61 @@
import type { ModuleChangelog } from '../module-registry/module-changelog';
/**
* Änderungen am Modul Nextcloud-Status, neueste Version zuerst
* (quick-261008-w5w). Die Version in `nextcloud-status.seed.ts` kommt
* ausschließlich von hier.
*/
export const NEXTCLOUD_STATUS_CHANGELOG: ModuleChangelog = [
{
version: '1.1.0',
date: '2026-10-08',
changes: [
{
kind: 'new',
de: 'Über den Kacheln gibt es ein Suchfeld: Es zeigt nur die Clouds, deren Kundenname den eingegebenen Text enthält.',
en: 'A search field above the tiles shows only the clouds whose customer name contains the text you type.',
},
{
kind: 'new',
de: 'Als Logo können Sie jetzt auch eine Bildadresse mit http:// eingeben; Tessera holt das Bild beim Speichern einmalig ab.',
en: 'You can now also enter an image address starting with http:// as the logo; Tessera fetches the image once when you save.',
},
{
kind: 'changed',
de: 'Die Kacheln sind kompakter, Kundenname und Adresse stehen in voller Länge da, und die Knöpfe sitzen unten rechts.',
en: 'The tiles are more compact, customer name and address are shown in full, and the buttons sit at the bottom right.',
},
{
kind: 'fixed',
de: 'Die Meldungen im Formular zum Hinzufügen und Bearbeiten einer Cloud erscheinen jetzt auf Deutsch.',
en: 'The messages in the form for adding and editing a cloud now appear in your chosen language.',
},
],
},
{
version: '1.0.0',
date: '2026-10-06',
changes: [
{
kind: 'new',
de: 'Für jede Nextcloud Ihrer Kunden eine Kachel mit Ampel (Version, Support-Ende, Erreichbarkeit); Tessera prüft stündlich, die Kacheln lassen sich sortieren.',
en: 'A tile with a traffic light (version, end of support, availability) for every customer Nextcloud; Tessera checks hourly and the tiles can be sorted.',
},
{
kind: 'new',
de: 'Die Glocke „Benachrichtigen“ meldet Störungen und Entwarnung per E-Mail und auf dem Bildschirm.',
en: 'The “Notify” bell reports problems and recoveries by email and on screen.',
},
{
kind: 'new',
de: 'Bei „Nicht erreichbar“ nennt die Kachel den Grund in Klartext.',
en: 'For “Not reachable”, the tile states the reason in plain words.',
},
{
kind: 'new',
de: 'Eine Dashboard-Kachel zeigt die Zähler für Grün, Gelb und Rot.',
en: 'A dashboard tile shows the counts for green, yellow and red.',
},
],
},
];
@@ -1,4 +1,6 @@
import { latestVersion } from '../module-registry/module-changelog';
import { ModuleRegistryService } from '../module-registry/module-registry.service';
import { NEXTCLOUD_STATUS_CHANGELOG } from './nextcloud-status.changelog';
/**
* Seeds the nextcloud-status module into the module registry
@@ -12,7 +14,8 @@ export async function seedNextcloudStatusModule(
await moduleRegistryService.seedModule({
slug: 'nextcloud-status',
name: 'Nextcloud-Status',
version: '1.0.0',
// Die Version kommt ausschließlich aus dem Changelog (quick-261008-w5w).
version: latestVersion(NEXTCLOUD_STATUS_CHANGELOG),
category: 'infrastructure',
description: {
de: 'Versionen und Erreichbarkeit Ihrer Nextcloud-Clouds im Blick',