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