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:
@@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user