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:
2026-09-23 08:00:59 +02:00
parent 9c518238f5
commit df7a5e7e8e
6 changed files with 698 additions and 1 deletions
@@ -0,0 +1,143 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { ForbiddenException } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { DashboardController } from './dashboard.controller';
/**
* dashboard.controller.spec — NEU (quick-260923-ad9, Task 2). Form aus
* `dashboard-images.controller.spec.ts`: die Reiter-Kennung wird aus
* Abfrageparameter bzw. Rumpf an den Dienst durchgereicht, fehlender
* Benutzer-/Mandantenkontext führt zur vorhandenen Abweisung, und ein
* quelltextlesender Wächter prüft, dass die feste Route `tabs/order` VOR
* jeder Route mit Platzhalter unter demselben Präfix im Dateitext steht
* (NestJS-Routenreihenfolge — in dieser Anwendung hat eine Route mit
* Platzhalter schon einmal eine dahinter stehende feste Route verdeckt).
*/
function makeService() {
return {
listDashboards: vi.fn(async () => [{ id: 'dash-1' }]),
createDashboard: vi.fn(async () => ({ id: 'dash-2' })),
reorderDashboards: vi.fn(async () => [{ id: 'dash-1' }, { id: 'dash-2' }]),
renameDashboard: vi.fn(async (id: string) => ({ id })),
deleteDashboard: vi.fn(async (id: string) => ({ id })),
getLayout: vi.fn(async () => ({ lg: [] })),
saveLayout: vi.fn(async () => ({ id: 'layout-1' })),
getWidgets: vi.fn(async () => []),
addWidget: vi.fn(async () => ({ id: 'w1' })),
updateWidgetConfig: vi.fn(async () => ({ id: 'w1' })),
removeWidget: vi.fn(async () => ({ id: 'w1' })),
getSearchProviders: vi.fn(async () => []),
addSearchProvider: vi.fn(async () => ({ id: 'sp1' })),
removeSearchProvider: vi.fn(async () => ({ id: 'sp1' })),
};
}
function makeRequest(overrides: Partial<AuthenticatedRequest> = {}): AuthenticatedRequest {
return {
user: { id: 'user-1', tenantId: 'tenant-1', role: 'USER', username: 'anna', mustChangePassword: false },
tenantId: 'tenant-1',
...overrides,
} as AuthenticatedRequest;
}
describe('DashboardController — Reiter (quick-260923-ad9, Task 2)', () => {
it('listDashboards: reicht userId/tenantId aus dem Sitzungsnachweis durch', async () => {
const service = makeService();
const controller = new DashboardController(service as never);
await controller.listDashboards(makeRequest());
expect(service.listDashboards).toHaveBeenCalledWith('user-1', 'tenant-1');
});
it('createDashboard: reicht userId/tenantId durch, kein Rumpf nötig', async () => {
const service = makeService();
const controller = new DashboardController(service as never);
await controller.createDashboard(makeRequest());
expect(service.createDashboard).toHaveBeenCalledWith('user-1', 'tenant-1');
});
it('reorderDashboards: reicht die Kennungsliste aus dem Rumpf durch', async () => {
const service = makeService();
const controller = new DashboardController(service as never);
const dto = { ids: ['dash-2', 'dash-1'] } as never;
await controller.reorderDashboards(makeRequest(), dto);
expect(service.reorderDashboards).toHaveBeenCalledWith('user-1', 'tenant-1', dto);
});
it('renameDashboard: reicht Pfad-Kennung und Rumpf durch', async () => {
const service = makeService();
const controller = new DashboardController(service as never);
const dto = { name: 'Neuer Name' } as never;
await controller.renameDashboard('dash-1', makeRequest(), dto);
expect(service.renameDashboard).toHaveBeenCalledWith('dash-1', 'user-1', 'tenant-1', dto);
});
it('deleteDashboard: reicht die Pfad-Kennung durch', async () => {
const service = makeService();
const controller = new DashboardController(service as never);
await controller.deleteDashboard('dash-1', makeRequest());
expect(service.deleteDashboard).toHaveBeenCalledWith('dash-1', 'user-1', 'tenant-1');
});
it('getLayout/getWidgets: reichen die Reiter-Kennung aus dem Abfrageparameter durch', async () => {
const service = makeService();
const controller = new DashboardController(service as never);
await controller.getLayout(makeRequest(), 'dash-1');
await controller.getWidgets(makeRequest(), 'dash-1');
expect(service.getLayout).toHaveBeenCalledWith('user-1', 'tenant-1', 'dash-1');
expect(service.getWidgets).toHaveBeenCalledWith('user-1', 'tenant-1', 'USER', 'dash-1');
});
it('fehlender Mandantenkontext führt bei den neuen Reiter-Wegen zur vorhandenen Abweisung', async () => {
const service = makeService();
const controller = new DashboardController(service as never);
const req = makeRequest({ user: undefined, tenantId: undefined } as never);
await expect(controller.listDashboards(req)).rejects.toBeInstanceOf(ForbiddenException);
await expect(controller.createDashboard(req)).rejects.toBeInstanceOf(ForbiddenException);
await expect(
controller.reorderDashboards(req, { ids: [] } as never),
).rejects.toBeInstanceOf(ForbiddenException);
await expect(
controller.renameDashboard('dash-1', req, { name: 'x' } as never),
).rejects.toBeInstanceOf(ForbiddenException);
await expect(controller.deleteDashboard('dash-1', req)).rejects.toBeInstanceOf(
ForbiddenException,
);
});
it('Wächter: die feste Route "tabs/order" steht im Dateitext VOR jeder Route mit Platzhalter unter demselben Präfix (NestJS-Routenreihenfolge)', () => {
const source = readFileSync(join(__dirname, 'dashboard.controller.ts'), 'utf-8');
const orderIndex = source.indexOf("@Put('tabs/order')");
const patchIdIndex = source.indexOf("@Patch('tabs/:id')");
const deleteIdIndex = source.indexOf("@Delete('tabs/:id')");
expect(orderIndex, '@Put(\'tabs/order\') fehlt im Quelltext').toBeGreaterThan(-1);
expect(patchIdIndex, '@Patch(\'tabs/:id\') fehlt im Quelltext').toBeGreaterThan(-1);
expect(deleteIdIndex, '@Delete(\'tabs/:id\') fehlt im Quelltext').toBeGreaterThan(-1);
expect(
orderIndex,
'tabs/order muss VOR PATCH tabs/:id deklariert sein, sonst verdeckt der Platzhalter die feste Route',
).toBeLessThan(patchIdIndex);
expect(
orderIndex,
'tabs/order muss VOR DELETE tabs/:id deklariert sein, sonst verdeckt der Platzhalter die feste Route',
).toBeLessThan(deleteIdIndex);
});
});
@@ -15,6 +15,8 @@ import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { DashboardService } from './dashboard.service'; import { DashboardService } from './dashboard.service';
import { CreateSearchProviderDto } from './dto/create-search-provider.dto'; import { CreateSearchProviderDto } from './dto/create-search-provider.dto';
import { CreateWidgetDto } from './dto/create-widget.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 { SaveLayoutDto } from './dto/save-layout.dto';
import { UpdateWidgetConfigDto } from './dto/update-widget-config.dto'; import { UpdateWidgetConfigDto } from './dto/update-widget-config.dto';
@@ -27,6 +29,12 @@ import { UpdateWidgetConfigDto } from './dto/update-widget-config.dto';
* *
* Routes: * Routes:
* - GET /dashboard/tabs — list the user's dashboard tabs (quick-260923-ad9) * - GET /dashboard/tabs — list the user's dashboard tabs (quick-260923-ad9)
* - POST /dashboard/tabs — create a new, empty tab
* - PUT /dashboard/tabs/order — persist the tab order (MUST be declared
* before the `:id` routes below, see the
* source-order guard in dashboard.controller.spec.ts)
* - PATCH /dashboard/tabs/:id — rename a tab
* - DELETE /dashboard/tabs/:id — delete a tab, its widgets and its layout
* - GET /dashboard/layout — get the saved layout of one tab * - GET /dashboard/layout — get the saved layout of one tab
* - PUT /dashboard/layout — upsert the layout of one tab * - PUT /dashboard/layout — upsert the layout of one tab
* - GET /dashboard/widgets — list the widget instances of one tab * - GET /dashboard/widgets — list the widget instances of one tab
@@ -78,6 +86,44 @@ export class DashboardController {
return this.dashboardService.listDashboards(userId, tenantId); return this.dashboardService.listDashboards(userId, tenantId);
} }
@Post('tabs')
async createDashboard(@Req() req: AuthenticatedRequest) {
const { userId, tenantId } = this.extractContext(req);
return this.dashboardService.createDashboard(userId, tenantId);
}
/**
* MUSS vor `PATCH tabs/:id` / `DELETE tabs/:id` stehen — in dieser
* Anwendung hat eine Route mit Platzhalter schon einmal eine dahinter
* stehende feste Route verdeckt (siehe Projektnotiz „NestJS Route-
* Order“); ein quelltextlesender Wächter in
* `dashboard.controller.spec.ts` prüft die Reihenfolge im Dateitext.
*/
@Put('tabs/order')
async reorderDashboards(
@Req() req: AuthenticatedRequest,
@Body() dto: ReorderDashboardsDto,
) {
const { userId, tenantId } = this.extractContext(req);
return this.dashboardService.reorderDashboards(userId, tenantId, dto);
}
@Patch('tabs/:id')
async renameDashboard(
@Param('id') id: string,
@Req() req: AuthenticatedRequest,
@Body() dto: RenameDashboardDto,
) {
const { userId, tenantId } = this.extractContext(req);
return this.dashboardService.renameDashboard(id, userId, tenantId, dto);
}
@Delete('tabs/:id')
async deleteDashboard(@Param('id') id: string, @Req() req: AuthenticatedRequest) {
const { userId, tenantId } = this.extractContext(req);
return this.dashboardService.deleteDashboard(id, userId, tenantId);
}
@Get('layout') @Get('layout')
async getLayout( async getLayout(
@Req() req: AuthenticatedRequest, @Req() req: AuthenticatedRequest,
@@ -43,7 +43,7 @@ vi.mock('../prisma/prisma-tenant.extension', () => ({
), ),
})); }));
import { ConflictException, NotFoundException } from '@nestjs/common'; import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { forTenant } from '../prisma/prisma-tenant.extension'; import { forTenant } from '../prisma/prisma-tenant.extension';
import { DashboardService } from './dashboard.service'; import { DashboardService } from './dashboard.service';
@@ -166,6 +166,28 @@ function makeFakePrisma(
dashboards.push(created); dashboards.push(created);
return created; return created;
}), }),
update: vi.fn(async ({ where, data }: any) => {
const dashboard = dashboards.find((d) => d.id === where.id);
if (!dashboard) return null;
Object.assign(dashboard, data);
return dashboard;
}),
updateMany: vi.fn(async ({ where, data }: any) => {
const matches = dashboards.filter(
(d) => d.id === where.id && d.userId === where.userId,
);
for (const d of matches) Object.assign(d, data);
return { count: matches.length };
}),
deleteMany: vi.fn(async ({ where }: any) => {
const before = dashboards.length;
for (let i = dashboards.length - 1; i >= 0; i--) {
if (dashboards[i].id === where.id && dashboards[i].userId === where.userId) {
dashboards.splice(i, 1);
}
}
return { count: before - dashboards.length };
}),
}, },
dashboardLayout: { dashboardLayout: {
findUnique: vi.fn(async ({ where }: any) => { findUnique: vi.fn(async ({ where }: any) => {
@@ -185,6 +207,17 @@ function makeFakePrisma(
}; };
return layoutRow; return layoutRow;
}), }),
deleteMany: vi.fn(async ({ where }: any) => {
if (
layoutRow &&
layoutRow.dashboardId === where.dashboardId &&
layoutRow.userId === where.userId
) {
layoutRow = null;
return { count: 1 };
}
return { count: 0 };
}),
}, },
widgetInstance: { widgetInstance: {
findMany: vi.fn(async ({ where }: any) => { findMany: vi.fn(async ({ where }: any) => {
@@ -207,6 +240,15 @@ function makeFakePrisma(
Object.assign(widget, data); Object.assign(widget, data);
return widget; return widget;
}), }),
deleteMany: vi.fn(async ({ where }: any) => {
const before = widgets.length;
for (let i = widgets.length - 1; i >= 0; i--) {
if (widgets[i].dashboardId === where.dashboardId && widgets[i].userId === where.userId) {
widgets.splice(i, 1);
}
}
return { count: before - widgets.length };
}),
delete: vi.fn(async ({ where }: any) => { delete: vi.fn(async ({ where }: any) => {
const idx = widgets.findIndex((w) => w.id === where.id); const idx = widgets.findIndex((w) => w.id === where.id);
if (idx === -1) return null; if (idx === -1) return null;
@@ -996,3 +1038,272 @@ describe('DashboardService — Suchmaschinen gebunden an forTenant(), Katalog be
} }
}); });
}); });
// --- Reiter anlegen/umbenennen/löschen/umsortieren (quick-260923-ad9, Task 2) ---
describe('DashboardService.createDashboard — Reiter anlegen (quick-260923-ad9, Task 2, D-08)', () => {
beforeEach(() => {
vi.mocked(forTenant).mockClear();
});
it('erzeugt "Dashboard 2", wenn nur "Dashboard" existiert', async () => {
const prisma = makeFakePrisma({ dashboards: [makeDashboard({ id: 'd1', name: 'Dashboard', position: 0 })] });
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
const result = await service.createDashboard('user-1', 'tenant-1');
expect(result.name).toBe('Dashboard 2');
});
it('erzeugt "Dashboard 3", wenn "Dashboard" und "Dashboard 2" existieren', async () => {
const prisma = makeFakePrisma({
dashboards: [
makeDashboard({ id: 'd1', name: 'Dashboard', position: 0 }),
makeDashboard({ id: 'd2', name: 'Dashboard 2', position: 1 }),
],
});
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
const result = await service.createDashboard('user-1', 'tenant-1');
expect(result.name).toBe('Dashboard 3');
});
it('füllt eine Lücke: "Dashboard" und "Dashboard 3" existieren -> "Dashboard 2"', async () => {
const prisma = makeFakePrisma({
dashboards: [
makeDashboard({ id: 'd1', name: 'Dashboard', position: 0 }),
makeDashboard({ id: 'd3', name: 'Dashboard 3', position: 1 }),
],
});
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
const result = await service.createDashboard('user-1', 'tenant-1');
expect(result.name).toBe('Dashboard 2');
});
it('hängt ans Ende — Position ist die höchste vorhandene plus eins', async () => {
const prisma = makeFakePrisma({
dashboards: [
makeDashboard({ id: 'd1', name: 'Dashboard', position: 0 }),
makeDashboard({ id: 'd2', name: 'Dashboard 2', position: 5 }),
],
});
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
const result = await service.createDashboard('user-1', 'tenant-1');
expect(result.position).toBe(6);
});
it('liefert den neuen Reiter mit leerer Kachelliste', async () => {
const prisma = makeFakePrisma({ dashboards: [makeDashboard({ id: 'd1' })] });
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
const created = await service.createDashboard('user-1', 'tenant-1');
const widgets = await service.getWidgets('user-1', 'tenant-1', 'USER' as any, created.id);
expect(widgets).toEqual([]);
});
it('wird ab 20 vorhandenen Reitern abgewiesen, ohne eine Zeile zu schreiben', async () => {
const dashboards = Array.from({ length: 20 }, (_, i) =>
makeDashboard({ id: `d${i}`, name: i === 0 ? 'Dashboard' : `Dashboard ${i + 1}`, position: i }),
);
const prisma = makeFakePrisma({ dashboards });
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
await expect(service.createDashboard('user-1', 'tenant-1')).rejects.toThrow();
expect(prisma.dashboard.create).not.toHaveBeenCalled();
});
});
describe('DashboardService.renameDashboard — Reiter umbenennen (quick-260923-ad9, Task 2)', () => {
beforeEach(() => {
vi.mocked(forTenant).mockClear();
});
it('benennt den eigenen Reiter um', async () => {
const prisma = makeFakePrisma({ dashboards: [makeDashboard({ id: DASH_1 })] });
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
const result = await service.renameDashboard(DASH_1, 'user-1', 'tenant-1', { name: 'Finanzen' } as any);
expect(result.name).toBe('Finanzen');
});
it('ein fremder Reiter führt zur Nicht-gefunden-Antwort', async () => {
const prisma = makeFakePrisma({
dashboards: [makeDashboard({ id: DASH_1, userId: 'other-user' })],
});
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
await expect(
service.renameDashboard(DASH_1, 'user-1', 'tenant-1', { name: 'x' } as any),
).rejects.toBeInstanceOf(NotFoundException);
});
});
describe('RenameDashboardDto — Beschneiden und Längenprüfung (quick-260923-ad9, Task 2)', () => {
it('beschneidet führende/nachgestellte Leerräume vor der Längenprüfung', async () => {
const { plainToInstance } = await import('class-transformer');
const { validate } = await import('class-validator');
const { RenameDashboardDto } = await import('./dto/rename-dashboard.dto');
const dto = plainToInstance(RenameDashboardDto, { name: ' Finanzen ' });
expect(dto.name).toBe('Finanzen');
expect(await validate(dto)).toEqual([]);
});
it('ein leerer Name (nach dem Beschneiden) wird abgewiesen', async () => {
const { plainToInstance } = await import('class-transformer');
const { validate } = await import('class-validator');
const { RenameDashboardDto } = await import('./dto/rename-dashboard.dto');
const dto = plainToInstance(RenameDashboardDto, { name: ' ' });
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0].property).toBe('name');
});
it('ein Name über 40 Zeichen wird abgewiesen', async () => {
const { plainToInstance } = await import('class-transformer');
const { validate } = await import('class-validator');
const { RenameDashboardDto } = await import('./dto/rename-dashboard.dto');
const dto = plainToInstance(RenameDashboardDto, { name: 'x'.repeat(41) });
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0].property).toBe('name');
});
});
describe('DashboardService.deleteDashboard — Reiter löschen (quick-260923-ad9, Task 2, D-10)', () => {
beforeEach(() => {
vi.mocked(forTenant).mockClear();
});
it('entfernt Reiter, seine Kacheln und seine Anordnung in EINER Transaktion und schreibt die verbleibenden Positionen lückenlos neu', async () => {
const prisma = makeFakePrisma({
dashboards: [
makeDashboard({ id: 'd1', position: 0 }),
makeDashboard({ id: 'd2', position: 1 }),
makeDashboard({ id: 'd3', position: 2 }),
],
widgets: [makeWidget({ id: 'w1', dashboardId: 'd2' })],
layout: { dashboardId: 'd2', userId: 'user-1', tenantId: 'tenant-1', layouts: {} },
});
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
await service.deleteDashboard('d2', 'user-1', 'tenant-1');
const remaining = await service.listDashboards('user-1', 'tenant-1');
expect(remaining.map((d: any) => d.id)).toEqual(['d1', 'd3']);
expect(remaining.map((d: any) => d.position)).toEqual([0, 1]);
expect(prisma.widgetInstance.deleteMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { dashboardId: 'd2', userId: 'user-1' } }),
);
expect(prisma.dashboardLayout.deleteMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { dashboardId: 'd2', userId: 'user-1' } }),
);
});
it('der letzte verbleibende Reiter kann nicht gelöscht werden — nichts wird geschrieben', async () => {
const prisma = makeFakePrisma({ dashboards: [makeDashboard({ id: 'd1' })] });
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
await expect(service.deleteDashboard('d1', 'user-1', 'tenant-1')).rejects.toBeInstanceOf(
ConflictException,
);
expect(prisma.dashboard.deleteMany).not.toHaveBeenCalled();
});
it('ein fremder Reiter führt zur Nicht-gefunden-Antwort', async () => {
const prisma = makeFakePrisma({
dashboards: [
makeDashboard({ id: 'd1', userId: 'other-user' }),
makeDashboard({ id: 'd2', userId: 'other-user', position: 1 }),
],
});
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
await expect(service.deleteDashboard('d1', 'user-1', 'tenant-1')).rejects.toBeInstanceOf(
NotFoundException,
);
});
});
describe('DashboardService.reorderDashboards — Reiter umsortieren (quick-260923-ad9, Task 2, T-AD9-04)', () => {
beforeEach(() => {
vi.mocked(forTenant).mockClear();
});
it('schreibt die Positionen 0…n-1 in der gesendeten Reihenfolge', async () => {
const prisma = makeFakePrisma({
dashboards: [
makeDashboard({ id: 'd1', position: 0 }),
makeDashboard({ id: 'd2', position: 1 }),
makeDashboard({ id: 'd3', position: 2 }),
],
});
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
const result = await service.reorderDashboards('user-1', 'tenant-1', {
ids: ['d3', 'd1', 'd2'],
} as any);
expect(result.map((d: any) => d.id)).toEqual(['d3', 'd1', 'd2']);
expect(result.map((d: any) => d.position)).toEqual([0, 1, 2]);
});
it('eine unvollständige Liste wird abgewiesen, ohne eine einzige Position zu ändern', async () => {
const prisma = makeFakePrisma({
dashboards: [
makeDashboard({ id: 'd1', position: 0 }),
makeDashboard({ id: 'd2', position: 1 }),
],
});
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
await expect(
service.reorderDashboards('user-1', 'tenant-1', { ids: ['d1'] } as any),
).rejects.toBeInstanceOf(BadRequestException);
const unchanged = await service.listDashboards('user-1', 'tenant-1');
expect(unchanged.map((d: any) => d.position)).toEqual([0, 1]);
});
it('eine unbekannte Kennung wird abgewiesen, ohne eine einzige Position zu ändern', async () => {
const prisma = makeFakePrisma({
dashboards: [
makeDashboard({ id: 'd1', position: 0 }),
makeDashboard({ id: 'd2', position: 1 }),
],
});
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
await expect(
service.reorderDashboards('user-1', 'tenant-1', { ids: ['d1', 'unknown'] } as any),
).rejects.toBeInstanceOf(BadRequestException);
const unchanged = await service.listDashboards('user-1', 'tenant-1');
expect(unchanged.map((d: any) => d.position)).toEqual([0, 1]);
});
it('die Kennung eines fremden Reiters wird abgewiesen, ohne eine einzige Position zu ändern', async () => {
const prisma = makeFakePrisma({
dashboards: [
makeDashboard({ id: 'd1', position: 0 }),
makeDashboard({ id: 'foreign', userId: 'other-user', position: 0 }),
],
});
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
await expect(
service.reorderDashboards('user-1', 'tenant-1', { ids: ['d1', 'foreign'] } as any),
).rejects.toBeInstanceOf(BadRequestException);
const unchanged = await service.listDashboards('user-1', 'tenant-1');
expect(unchanged.map((d: any) => d.position)).toEqual([0]);
});
});
+154
View File
@@ -1,4 +1,5 @@
import { import {
BadRequestException,
ConflictException, ConflictException,
Injectable, Injectable,
NotFoundException, NotFoundException,
@@ -9,10 +10,18 @@ import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extens
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { CreateSearchProviderDto } from './dto/create-search-provider.dto'; import { CreateSearchProviderDto } from './dto/create-search-provider.dto';
import { CreateWidgetDto } from './dto/create-widget.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 { SaveLayoutDto } from './dto/save-layout.dto';
import { UpdateWidgetConfigDto } from './dto/update-widget-config.dto'; import { UpdateWidgetConfigDto } from './dto/update-widget-config.dto';
import { getModuleSlugForWidgetType } from './widget-module-map'; 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). * Default search providers (D-15).
* Returned as part of getSearchProviders even when no DB rows exist. * 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 * Returns the saved layout of one dashboard tab, or a default empty
* layout with all breakpoint arrays initialized. * layout with all breakpoint arrays initialized.
@@ -0,0 +1,17 @@
import { Transform } from 'class-transformer';
import { IsString, Length } from 'class-validator';
/**
* DTO for `PATCH /dashboard/tabs/:id` (quick-260923-ad9, Task 2).
*
* `name` wird VOR der Längenprüfung beschnitten (führende/nachgestellte
* Leerräume zählen nicht mit) — ein reiner Leerraum-Name schlägt danach an
* `@Length(1, 40)` fehl. Die Obergrenze von 40 Zeichen ist ein Riegel gegen
* Massenanfragen, kein UI-Detail (T-AD9-06).
*/
export class RenameDashboardDto {
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@Length(1, 40)
name!: string;
}
@@ -0,0 +1,26 @@
import {
ArrayMaxSize,
ArrayMinSize,
ArrayUnique,
IsArray,
IsString,
} from 'class-validator';
/**
* DTO for `PUT /dashboard/tabs/order` (quick-260923-ad9, Task 2).
*
* `ids` ist die VOLLSTÄNDIGE Kennungsliste der Reiter des Benutzers, in
* der gewünschten Reihenfolge — der Dienst verlangt einen exakten Abgleich
* gegen die vorhandenen Reiter (kein Teil-Umsortieren, keine fremden/
* unbekannten Kennungen), Muster `ReorderFavoritesDto`/`FavoritesService.
* reorder` (260917-jdd). `ArrayMaxSize(20)` ist ein Riegel gegen
* Massenanfragen (T-AD9-06) — ein Benutzer hat höchstens 20 Reiter.
*/
export class ReorderDashboardsDto {
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(20)
@ArrayUnique()
@IsString({ each: true })
ids!: string[];
}