refactor(quick-260922-m1h): Widget-Typen an einer Stelle, Katalog aus der Registry, Kachel kennt ihr Modul

Ein neuer Widget-Typ war an sieben Stellen einzutragen; vergass man eine,
fehlte die Kachel im Katalog oder die API lehnte sie mit 400 ab.

- WIDGET_TYPES/WidgetType/WIDGET_MODULE_SLUGS stehen jetzt einmal in
  packages/shared; Registry, Katalog und die @IsIn-Whitelist der API
  leiten davon ab
- neun wireXWidget()-Funktionen durch ein generisches registerWidget()
  ersetzt (idempotent, unbekannter Typ wirft in der Entwicklung)
- der Katalog fuehrt keine zweite Typliste mehr, sondern leitet sie aus
  der Registry ab und filtert nach Modulzugriff (fail-closed, wenn die
  Modulliste unbekannt ist); der Abruf von /modules/active liegt auf der
  Dashboard-Seite, nicht im Dialog
- widget-module-map.ts liest die geteilte Tabelle statt einer Kopie, die
  oeffentliche Funktion bleibt unveraendert

Der Katalogfilter ist Komfort (T-M1H-01) — verbindlich bleibt der
serverseitige Filter in DashboardService.getWidgets.

Abweichung vom Plan: apps/web hing entgegen der Planannahme noch nicht
von @tessera/shared ab; die Abhaengigkeit wurde ergaenzt (Lockfile). Die
Dockerfiles kopieren packages/shared bereits, der Produktionsbau von
Next.js und der nest build laufen unveraendert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-22 16:03:58 +02:00
parent ee2b0256b5
commit 56c07c3581
14 changed files with 535 additions and 146 deletions
@@ -1,24 +1,20 @@
import { IsIn, IsObject, IsOptional, IsString } from 'class-validator';
import { WIDGET_TYPES } from '@tessera/shared';
/**
* DTO for creating a new widget instance on a user's dashboard.
* widgetType must be one of the nine supported types
* ('picture-frame' seit quick-260921-pi9, 'xframe' seit quick-260921-qd3).
*
* quick-260922-m1h: `widgetType` wird gegen `WIDGET_TYPES` aus
* `@tessera/shared` geprüft — dieselbe Liste, aus der das Frontend seine
* Registry und seinen Katalog ableitet. Vorher stand die Liste hier ein
* zweites Mal; vergaß man einen Eintrag, lehnte die API eine im Katalog
* angebotene Kachel mit 400 ab.
*
* config is optional and defaults to {} on the model.
*/
export class CreateWidgetDto {
@IsString()
@IsIn([
'clock',
'search',
'calendar',
'note',
'calculator',
'favorites',
'stopwatch',
'picture-frame',
'xframe',
])
@IsIn([...WIDGET_TYPES])
widgetType!: string;
@IsOptional()
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { WIDGET_MODULE_SLUGS, WIDGET_TYPES } from '@tessera/shared';
import { CreateWidgetDto } from './dto/create-widget.dto';
import { WIDGET_MODULE_MAP, getModuleSlugForWidgetType } from './widget-module-map';
/**
* quick-260922-m1h: Web und API lesen dieselbe Tabelle. Liefen sie
* auseinander, wuerde der Katalog eine Kachel anbieten, die der Server
* danach wieder herausfiltert (oder umgekehrt).
*/
describe('widget-module-map (quick-260922-m1h)', () => {
it('WIDGET_MODULE_MAP ist die Tabelle aus @tessera/shared, keine zweite Kopie', () => {
expect(WIDGET_MODULE_MAP).toBe(WIDGET_MODULE_SLUGS);
});
it('jeder Schluessel der Tabelle ist ein bekannter Widget-Typ', () => {
for (const type of Object.keys(WIDGET_MODULE_MAP)) {
expect(WIDGET_TYPES).toContain(type);
}
});
it('die neun heutigen Kacheln sind Plattform-Kacheln ohne Modulbezug', () => {
for (const type of WIDGET_TYPES) {
expect(getModuleSlugForWidgetType(type)).toBeUndefined();
}
});
it('ein unbekannter Typ liefert undefined statt zu werfen', () => {
expect(getModuleSlugForWidgetType('gibt-es-nicht')).toBeUndefined();
});
});
/**
* Der Kern des Umbaus: die `@IsIn`-Whitelist ist keine handgepflegte zweite
* Liste mehr. Vergisst kuenftig jemand einen Eintrag in `packages/shared`,
* schlaegt dieser Test fehl, statt die API eine gueltige Kachel mit 400
* ablehnen zu lassen.
*/
describe('CreateWidgetDto-Whitelist (quick-260922-m1h)', () => {
async function validateType(widgetType: string) {
const dto = plainToInstance(CreateWidgetDto, { widgetType });
return validate(dto);
}
it.each([...WIDGET_TYPES])('akzeptiert den Typ "%s"', async (widgetType) => {
await expect(validateType(widgetType)).resolves.toEqual([]);
});
it('lehnt einen Typ ab, der nicht in WIDGET_TYPES steht', async () => {
const errors = await validateType('gibt-es-nicht');
expect(errors).toHaveLength(1);
expect(errors[0].property).toBe('widgetType');
expect(errors[0].constraints).toHaveProperty('isIn');
});
});
+16 -9
View File
@@ -1,3 +1,5 @@
import { WIDGET_MODULE_SLUGS } from '@tessera/shared';
/**
* Zuordnung Widget-Typ → Modul (D-22, PERM-07).
*
@@ -8,24 +10,29 @@
* Schlüssel sind Werte von `WidgetInstance.widgetType`, Werte sind
* Modul-Slugs aus `Module.slug`.
*
* quick-260922-m1h: Die Tabelle selbst steht seit diesem Umbau in
* `packages/shared/src/index.ts` als `WIDGET_MODULE_SLUGS` — EINE Tabelle
* für beide Seiten, damit der Katalogfilter im Web
* (`visibleWidgetTypes`, Komfort) und dieser Server-Filter (verbindlich)
* nicht auseinanderlaufen. Hier steht nur noch der Lesezugriff; die
* öffentliche Schnittstelle dieser Datei bleibt unverändert, weil
* dashboard.service.spec.ts sie gezielt mockt.
*
* Bewusst eine TypeScript-Konstante statt einer Spalte auf
* `WidgetInstance`: eine Migration auf einer bereits befüllten Tabelle
* für ein Feld, das derzeit für jede Zeile leer wäre, wiegt schwerer als
* diese Konstante mit identischer Aussagekraft (15-RESEARCH.md Pitfall 5).
*
* Die Tabelle ist am Ende dieser Phase bewusst leer: alle sieben heute
* registrierten Widget-Typen (clock/search/calendar/note/calculator/
* favorites/stopwatch, siehe apps/web/src/components/dashboard/
* widget-registry.tsx) sind Plattform-Widgets ohne Modulbezug. Das
* einzige bislang geplante modulgebundene Widget steht in
* .planning/REQUIREMENTS.md unter "Future Requirements (deferred)" und
* wird in dieser Phase bewusst nicht registriert.
* Die Tabelle ist bewusst leer: alle neun registrierten Widget-Typen
* (clock/search/calendar/note/calculator/favorites/stopwatch/
* picture-frame/xframe) sind Plattform-Widgets ohne Modulbezug. Die erste
* modulgebundene Kachel trägt ihren Slug in `WIDGET_MODULE_SLUGS` ein.
*/
export const WIDGET_MODULE_MAP: Readonly<Record<string, string>> = {};
export const WIDGET_MODULE_MAP: Readonly<Record<string, string>> = WIDGET_MODULE_SLUGS;
/**
* Liefert den Modul-Slug für einen Widget-Typ, oder `undefined`, wenn
* der Typ kein Modul-Widget ist (der heutige Zustand für alle sieben
* der Typ kein Modul-Widget ist (der heutige Zustand für alle neun
* bestehenden Typen). Einziger Lesezugriff auf die Zuordnungstabelle,
* damit Tests sie gezielt mocken können.
*/