feat(quick-260923-ad9): Reiter anlegen, umbenennen, loeschen, umsortieren - Task 2
dashboard.service.ts: createDashboard() (Namensvergabe "Dashboard N" fuellt Luecken, D-08; Obergrenze 20 Reiter, T-AD9-06), renameDashboard() (Riegel zuerst), deleteDashboard() (letzter Reiter bleibt, D-10; Loeschen + Neu- Nummerierung als EINE withTenantTransaction), reorderDashboards() (woertlich nach FavoritesService.reorder-Muster: Exakt-Abgleich vor jedem Schreiben, kein Teilschreiben, dieselbe Abweisung fuer unvollstaendige/unbekannte/ fremde Kennungen - T-AD9-04). dashboard.controller.ts: fuenf neue Wege unter tabs; PUT tabs/order VOR PATCH/DELETE tabs/:id deklariert (Routenreihenfolge). dashboard.controller.spec.ts (neu, 8 Tests): Durchreichung, Abweisung ohne Kontext, quelltextlesender Waechter fuer die Routenreihenfolge. dashboard.service.spec.ts: 61 Tests (43 alte + 18 neue fuer Anlegen, Umbenennen inkl. DTO-Beschneidung/-Laengenpruefung, Loeschen und Umsortieren - je ein Fall fuer "fremder Reiter" und "letzter Reiter bleibt"). Deviation (Rule 1): widget-module-map.spec.ts's CreateWidgetDto-Whitelist helper needed a dashboardId fixture after Task 1 made the field required - fixed inline, out of the plan's files_modified list but directly caused by Task 1's DTO change. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
@@ -9,10 +10,18 @@ import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extens
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateSearchProviderDto } from './dto/create-search-provider.dto';
|
||||
import { CreateWidgetDto } from './dto/create-widget.dto';
|
||||
import { RenameDashboardDto } from './dto/rename-dashboard.dto';
|
||||
import { ReorderDashboardsDto } from './dto/reorder-dashboards.dto';
|
||||
import { SaveLayoutDto } from './dto/save-layout.dto';
|
||||
import { UpdateWidgetConfigDto } from './dto/update-widget-config.dto';
|
||||
import { getModuleSlugForWidgetType } from './widget-module-map';
|
||||
|
||||
/**
|
||||
* T-AD9-06 — Riegel gegen Massenanfragen: hoechstens 20 Reiter je Benutzer
|
||||
* (quick-260923-ad9, Task 2).
|
||||
*/
|
||||
const DASHBOARD_MAX_COUNT = 20;
|
||||
|
||||
/**
|
||||
* Default search providers (D-15).
|
||||
* Returned as part of getSearchProviders even when no DB rows exist.
|
||||
@@ -157,6 +166,151 @@ export class DashboardService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt einen neuen, leeren Reiter an (quick-260923-ad9, Task 2, D-08).
|
||||
* Name automatisch: "Dashboard 2", "Dashboard 3", … — die kleinste noch
|
||||
* freie Zahl ab 2 (füllt eine Lücke, wenn z. B. "Dashboard 2" gelöscht
|
||||
* wurde). Dieser Name ist ein gespeicherter Datenwert, keine
|
||||
* Oberflächenbeschriftung — deshalb ein TypeScript-Text hier statt eines
|
||||
* Übersetzungsschlüssels, genau wie der Name "Dashboard", den die
|
||||
* Migration/`listDashboards` vergeben. Hängt ans Ende (höchste
|
||||
* vorhandene Position plus eins) und liefert den neuen Reiter mit
|
||||
* leerer Kachelliste (es existiert noch keine `WidgetInstance`-Zeile
|
||||
* dafür).
|
||||
*/
|
||||
async createDashboard(userId: string, tenantId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
const existing = await tenantPrisma.dashboard.findMany({ where: { userId } });
|
||||
|
||||
if (existing.length >= DASHBOARD_MAX_COUNT) {
|
||||
throw new BadRequestException(
|
||||
`Es sind bereits ${DASHBOARD_MAX_COUNT} Dashboards vorhanden — mehr sind nicht möglich.`,
|
||||
);
|
||||
}
|
||||
|
||||
const existingNames = new Set(existing.map((d) => d.name));
|
||||
let n = 2;
|
||||
while (existingNames.has(`Dashboard ${n}`)) n++;
|
||||
|
||||
const nextPosition = existing.reduce((max, d) => Math.max(max, d.position), -1) + 1;
|
||||
|
||||
return tenantPrisma.dashboard.create({
|
||||
data: { userId, tenantId, name: `Dashboard ${n}`, position: nextPosition },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Benennt einen Reiter um (quick-260923-ad9, Task 2). `assertOwnedDashboard`
|
||||
* läuft zuerst, über denselben gebundenen Klienten — eine fremde Kennung
|
||||
* liefert die Nicht-gefunden-Antwort (T-AD9-03). Beschneiden und
|
||||
* Längenprüfung (1–40 Zeichen) liegen bereits im DTO.
|
||||
*/
|
||||
async renameDashboard(
|
||||
id: string,
|
||||
userId: string,
|
||||
tenantId: string,
|
||||
dto: RenameDashboardDto,
|
||||
) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
await this.assertOwnedDashboard(tenantPrisma, id, userId);
|
||||
|
||||
return tenantPrisma.dashboard.update({
|
||||
where: { id },
|
||||
data: { name: dto.name },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Löscht einen Reiter mit seinen Kacheln und seiner Anordnung
|
||||
* (quick-260923-ad9, Task 2). `assertOwnedDashboard` läuft zuerst; danach
|
||||
* wird geprüft, ob es der letzte verbleibende Reiter ist (D-10) — der
|
||||
* Server weist das ab, die Oberfläche bietet den Knopf dafür gar nicht
|
||||
* erst an. Löschen, Anordnung-/Kachel-Entfernen und das lückenlose
|
||||
* Neuschreiben der verbleibenden Positionen laufen als EINE
|
||||
* `withTenantTransaction` (mehrschrittig, muss atomar sein — dieselbe
|
||||
* Begründung wie `FavoritesService.reorder`). Die Löschweitergabe in der
|
||||
* Datenbank (`onDelete: Cascade`) bleibt als zweites Netz bestehen; der
|
||||
* geschriebene Weg unten ist der gebundene. `withTenantTransaction`
|
||||
* setzt keine Benutzerdimension in der Sitzung — jede Bedingung trägt
|
||||
* `userId` deshalb selbst.
|
||||
*/
|
||||
async deleteDashboard(id: string, userId: string, tenantId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
await this.assertOwnedDashboard(tenantPrisma, id, userId);
|
||||
|
||||
const count = await tenantPrisma.dashboard.count({ where: { userId, tenantId } });
|
||||
if (count <= 1) {
|
||||
throw new ConflictException('Der letzte verbleibende Reiter kann nicht gelöscht werden.');
|
||||
}
|
||||
|
||||
return withTenantTransaction(this.prisma, tenantId, async (tx) => {
|
||||
await tx.widgetInstance.deleteMany({ where: { dashboardId: id, userId } });
|
||||
await tx.dashboardLayout.deleteMany({ where: { dashboardId: id, userId } });
|
||||
await tx.dashboard.deleteMany({ where: { id, userId } });
|
||||
|
||||
const remaining = await tx.dashboard.findMany({
|
||||
where: { userId },
|
||||
orderBy: { position: 'asc' },
|
||||
});
|
||||
|
||||
for (const [index, dashboard] of remaining.entries()) {
|
||||
await tx.dashboard.updateMany({
|
||||
where: { id: dashboard.id, userId },
|
||||
data: { position: index },
|
||||
});
|
||||
}
|
||||
|
||||
return { id };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistiert die Reihenfolge der Reiter des Benutzers
|
||||
* (quick-260923-ad9, Task 2). Wörtlich nach dem Muster
|
||||
* `FavoritesService.reorder` (260917-jdd): EINE `withTenantTransaction`,
|
||||
* darin erst die vorhandenen Kennungen lesen, auf exakte Übereinstimmung
|
||||
* mit der gesendeten Liste prüfen (sonst Abweisung, KEIN Teilschreiben —
|
||||
* die Prüfung läuft VOR jedem `updateMany`), dann je Eintrag ein
|
||||
* `updateMany` mit `id` UND `userId` in der Bedingung und einer Prüfung
|
||||
* auf genau eine getroffene Zeile (T-AD9-04). Existenzorakel-Vermeidung:
|
||||
* EINE `BadRequestException` mit DERSELBEN Meldung für unvollständige,
|
||||
* unbekannte und fremde Kennungen — kein Fall verrät, welcher Grund
|
||||
* zutraf (Muster T-GWH-05/T-JDD-06).
|
||||
*/
|
||||
async reorderDashboards(userId: string, tenantId: string, dto: ReorderDashboardsDto) {
|
||||
if (new Set(dto.ids).size !== dto.ids.length) {
|
||||
throw new BadRequestException('ids must match the dashboards of this user exactly');
|
||||
}
|
||||
|
||||
return withTenantTransaction(this.prisma, tenantId, async (tx) => {
|
||||
const existing = await tx.dashboard.findMany({
|
||||
where: { userId },
|
||||
select: { id: true },
|
||||
});
|
||||
const existingIds = new Set(existing.map((r: { id: string }) => r.id));
|
||||
|
||||
if (existing.length !== dto.ids.length || dto.ids.some((id) => !existingIds.has(id))) {
|
||||
throw new BadRequestException('ids must match the dashboards of this user exactly');
|
||||
}
|
||||
|
||||
for (const [index, id] of dto.ids.entries()) {
|
||||
const { count } = await tx.dashboard.updateMany({
|
||||
where: { id, userId },
|
||||
data: { position: index },
|
||||
});
|
||||
|
||||
if (count !== 1) {
|
||||
throw new BadRequestException('ids must match the dashboards of this user exactly');
|
||||
}
|
||||
}
|
||||
|
||||
return tx.dashboard.findMany({
|
||||
where: { userId },
|
||||
orderBy: { position: 'asc' },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the saved layout of one dashboard tab, or a default empty
|
||||
* layout with all breakpoint arrays initialized.
|
||||
|
||||
Reference in New Issue
Block a user