From df7a5e7e8ef15a126a92bd45cbfdca87993628b4 Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 23 Sep 2026 08:00:59 +0200 Subject: [PATCH] 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 --- .../dashboard/dashboard.controller.spec.ts | 143 ++++++++ .../api/src/dashboard/dashboard.controller.ts | 46 +++ .../src/dashboard/dashboard.service.spec.ts | 313 +++++++++++++++++- apps/api/src/dashboard/dashboard.service.ts | 154 +++++++++ .../src/dashboard/dto/rename-dashboard.dto.ts | 17 + .../dashboard/dto/reorder-dashboards.dto.ts | 26 ++ 6 files changed, 698 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/dashboard/dashboard.controller.spec.ts create mode 100644 apps/api/src/dashboard/dto/rename-dashboard.dto.ts create mode 100644 apps/api/src/dashboard/dto/reorder-dashboards.dto.ts diff --git a/apps/api/src/dashboard/dashboard.controller.spec.ts b/apps/api/src/dashboard/dashboard.controller.spec.ts new file mode 100644 index 0000000..5c6dce9 --- /dev/null +++ b/apps/api/src/dashboard/dashboard.controller.spec.ts @@ -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 { + 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); + }); +}); diff --git a/apps/api/src/dashboard/dashboard.controller.ts b/apps/api/src/dashboard/dashboard.controller.ts index 3eedcbd..b70ab10 100644 --- a/apps/api/src/dashboard/dashboard.controller.ts +++ b/apps/api/src/dashboard/dashboard.controller.ts @@ -15,6 +15,8 @@ import type { AuthenticatedRequest } from '../auth/types/auth-user'; import { DashboardService } from './dashboard.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'; @@ -27,6 +29,12 @@ import { UpdateWidgetConfigDto } from './dto/update-widget-config.dto'; * * Routes: * - 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 * - PUT /dashboard/layout — upsert the layout 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); } + @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') async getLayout( @Req() req: AuthenticatedRequest, diff --git a/apps/api/src/dashboard/dashboard.service.spec.ts b/apps/api/src/dashboard/dashboard.service.spec.ts index 48e1407..84a1b14 100644 --- a/apps/api/src/dashboard/dashboard.service.spec.ts +++ b/apps/api/src/dashboard/dashboard.service.spec.ts @@ -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 { forTenant } from '../prisma/prisma-tenant.extension'; import { DashboardService } from './dashboard.service'; @@ -166,6 +166,28 @@ function makeFakePrisma( dashboards.push(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: { findUnique: vi.fn(async ({ where }: any) => { @@ -185,6 +207,17 @@ function makeFakePrisma( }; 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: { findMany: vi.fn(async ({ where }: any) => { @@ -207,6 +240,15 @@ function makeFakePrisma( Object.assign(widget, data); 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) => { const idx = widgets.findIndex((w) => w.id === where.id); 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]); + }); +}); diff --git a/apps/api/src/dashboard/dashboard.service.ts b/apps/api/src/dashboard/dashboard.service.ts index 3df5eef..81790d5 100644 --- a/apps/api/src/dashboard/dashboard.service.ts +++ b/apps/api/src/dashboard/dashboard.service.ts @@ -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. diff --git a/apps/api/src/dashboard/dto/rename-dashboard.dto.ts b/apps/api/src/dashboard/dto/rename-dashboard.dto.ts new file mode 100644 index 0000000..9579261 --- /dev/null +++ b/apps/api/src/dashboard/dto/rename-dashboard.dto.ts @@ -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; +} diff --git a/apps/api/src/dashboard/dto/reorder-dashboards.dto.ts b/apps/api/src/dashboard/dto/reorder-dashboards.dto.ts new file mode 100644 index 0000000..8538f7b --- /dev/null +++ b/apps/api/src/dashboard/dto/reorder-dashboards.dto.ts @@ -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[]; +}