From 950eebbc154c0990602f92379e61176f4d47c17a Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 24 Jun 2026 11:14:03 +0200 Subject: [PATCH] =?UTF-8?q?feat(05-01):=20dashboard=20backend=20=E2=80=94?= =?UTF-8?q?=20Prisma=20models,=20CRUD=20API,=20module=20wiring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add DashboardLayout and WidgetInstance Prisma models with userId/tenantId scoping - Create DashboardController with 6 endpoints (layout CRUD + widget CRUD) - Create DashboardService with ownership verification on all widget mutations (T-05-01) - Add SaveLayoutDto, CreateWidgetDto, UpdateWidgetConfigDto with class-validator - Register DashboardModule in app.module.ts imports Co-Authored-By: Claude Sonnet 4.6 --- apps/api/prisma/schema.prisma | 24 ++++ apps/api/src/app.module.ts | 2 + .../api/src/dashboard/dashboard.controller.ts | 95 +++++++++++++ apps/api/src/dashboard/dashboard.module.ts | 19 +++ apps/api/src/dashboard/dashboard.service.ts | 129 ++++++++++++++++++ .../src/dashboard/dto/create-widget.dto.ts | 16 +++ apps/api/src/dashboard/dto/save-layout.dto.ts | 11 ++ .../dashboard/dto/update-widget-config.dto.ts | 10 ++ 8 files changed, 306 insertions(+) create mode 100644 apps/api/src/dashboard/dashboard.controller.ts create mode 100644 apps/api/src/dashboard/dashboard.module.ts create mode 100644 apps/api/src/dashboard/dashboard.service.ts create mode 100644 apps/api/src/dashboard/dto/create-widget.dto.ts create mode 100644 apps/api/src/dashboard/dto/save-layout.dto.ts create mode 100644 apps/api/src/dashboard/dto/update-widget-config.dto.ts diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 1e8e461..fe42114 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -110,3 +110,27 @@ model TenantModuleActivation { @@unique([tenantId, moduleId]) @@index([tenantId]) } + +model DashboardLayout { + id String @id @default(uuid()) + userId String @unique + tenantId String + layouts Json @default("{}") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([tenantId]) +} + +model WidgetInstance { + id String @id @default(uuid()) + userId String + tenantId String + widgetType String + config Json @default("{}") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) + @@index([tenantId]) +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 6f61374..4fae6fc 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -8,6 +8,7 @@ import { ForcePasswordChangeInterceptor } from './auth/interceptors/force-passwo import { HealthModule } from './health/health.module'; import { LdapModule } from './ldap/ldap.module'; import { MailModule } from './mail/mail.module'; +import { DashboardModule } from './dashboard/dashboard.module'; import { DomaincheckModule } from './domaincheck/domaincheck.module'; import { ModuleRegistryModule } from './module-registry/module-registry.module'; import { PrismaModule } from './prisma/prisma.module'; @@ -27,6 +28,7 @@ import { UserModule } from './user/user.module'; LdapModule, ModuleRegistryModule, DomaincheckModule, + DashboardModule, ], providers: [ // Global JWT guard: all routes require auth unless @Public() diff --git a/apps/api/src/dashboard/dashboard.controller.ts b/apps/api/src/dashboard/dashboard.controller.ts new file mode 100644 index 0000000..3df19f4 --- /dev/null +++ b/apps/api/src/dashboard/dashboard.controller.ts @@ -0,0 +1,95 @@ +import { + Body, + Controller, + Delete, + ForbiddenException, + Get, + Param, + Patch, + Post, + Put, + Req, +} from '@nestjs/common'; +import { Request } from 'express'; +import { DashboardService } from './dashboard.service'; +import { CreateWidgetDto } from './dto/create-widget.dto'; +import { SaveLayoutDto } from './dto/save-layout.dto'; +import { UpdateWidgetConfigDto } from './dto/update-widget-config.dto'; + +/** + * REST controller for dashboard layout and widget instance management. + * + * All endpoints require JWT auth (global JwtAuthGuard). + * Every handler extracts userId and tenantId from the request + * and scopes all operations to the calling user (T-05-01, T-05-02). + * + * Routes: + * - GET /dashboard/layout — get user's saved layout + * - PUT /dashboard/layout — upsert user's layout + * - GET /dashboard/widgets — list user's widget instances + * - POST /dashboard/widgets — create a new widget instance + * - PATCH /dashboard/widgets/:id/config — update widget config + * - DELETE /dashboard/widgets/:id — remove a widget instance + */ +@Controller('dashboard') +export class DashboardController { + constructor(private readonly dashboardService: DashboardService) {} + + private extractContext(req: Request) { + const userId = (req as any).user?.id; + const tenantId = + (req as any).tenantId ?? (req as any).user?.tenantId; + + if (!tenantId) { + throw new ForbiddenException('No tenant context'); + } + if (!userId) { + throw new ForbiddenException('No user context'); + } + + return { userId, tenantId }; + } + + @Get('layout') + async getLayout(@Req() req: Request) { + const { userId } = this.extractContext(req); + return this.dashboardService.getLayout(userId); + } + + @Put('layout') + async saveLayout(@Req() req: Request, @Body() dto: SaveLayoutDto) { + const { userId, tenantId } = this.extractContext(req); + return this.dashboardService.saveLayout(userId, tenantId, dto); + } + + @Get('widgets') + async getWidgets(@Req() req: Request) { + const { userId } = this.extractContext(req); + return this.dashboardService.getWidgets(userId); + } + + @Post('widgets') + async addWidget(@Req() req: Request, @Body() dto: CreateWidgetDto) { + const { userId, tenantId } = this.extractContext(req); + return this.dashboardService.addWidget(userId, tenantId, dto); + } + + @Patch('widgets/:id/config') + async updateWidgetConfig( + @Param('id') id: string, + @Req() req: Request, + @Body() dto: UpdateWidgetConfigDto, + ) { + const { userId } = this.extractContext(req); + return this.dashboardService.updateWidgetConfig(id, userId, dto); + } + + @Delete('widgets/:id') + async removeWidget( + @Param('id') id: string, + @Req() req: Request, + ) { + const { userId } = this.extractContext(req); + return this.dashboardService.removeWidget(id, userId); + } +} diff --git a/apps/api/src/dashboard/dashboard.module.ts b/apps/api/src/dashboard/dashboard.module.ts new file mode 100644 index 0000000..0caefab --- /dev/null +++ b/apps/api/src/dashboard/dashboard.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { DashboardController } from './dashboard.controller'; +import { DashboardService } from './dashboard.service'; + +/** + * NestJS module for dashboard layout and widget management. + * + * Provides: + * - DashboardService: CRUD for per-user dashboard layouts and widget instances + * - DashboardController: REST API for layout and widget operations + * + * Exports DashboardService so downstream modules can access layout/widget data. + */ +@Module({ + controllers: [DashboardController], + providers: [DashboardService], + exports: [DashboardService], +}) +export class DashboardModule {} diff --git a/apps/api/src/dashboard/dashboard.service.ts b/apps/api/src/dashboard/dashboard.service.ts new file mode 100644 index 0000000..d642602 --- /dev/null +++ b/apps/api/src/dashboard/dashboard.service.ts @@ -0,0 +1,129 @@ +import { + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreateWidgetDto } from './dto/create-widget.dto'; +import { SaveLayoutDto } from './dto/save-layout.dto'; +import { UpdateWidgetConfigDto } from './dto/update-widget-config.dto'; + +/** + * Service managing per-user dashboard layouts and widget instances. + * + * Layout (position/size) and widget config are stored in separate models + * to avoid unnecessary saves when only one changes (RESEARCH anti-pattern). + * + * All operations are scoped by userId for security (T-05-01, T-05-02). + */ +@Injectable() +export class DashboardService { + constructor(private readonly prisma: PrismaService) {} + + /** + * Returns the user's saved layout, or a default empty layout + * with all breakpoint arrays initialized. + */ + async getLayout(userId: string) { + const record = await this.prisma.dashboardLayout.findUnique({ + where: { userId }, + }); + + if (!record) { + return { lg: [], md: [], sm: [], xs: [], xxs: [] }; + } + + return record.layouts; + } + + /** + * Upserts the user's dashboard layout. + * Creates a new record if none exists, updates if it does. + */ + async saveLayout(userId: string, tenantId: string, dto: SaveLayoutDto) { + return this.prisma.dashboardLayout.upsert({ + where: { userId }, + update: { layouts: dto.layouts as unknown as Prisma.InputJsonValue }, + create: { + userId, + tenantId, + layouts: dto.layouts as unknown as Prisma.InputJsonValue, + }, + }); + } + + /** + * Returns all widget instances for a given user. + */ + async getWidgets(userId: string) { + return this.prisma.widgetInstance.findMany({ + where: { userId }, + orderBy: { createdAt: 'asc' }, + }); + } + + /** + * Creates a new widget instance for the user. + */ + async addWidget(userId: string, tenantId: string, dto: CreateWidgetDto) { + return this.prisma.widgetInstance.create({ + data: { + userId, + tenantId, + widgetType: dto.widgetType, + config: (dto.config ?? {}) as unknown as Prisma.InputJsonValue, + }, + }); + } + + /** + * Updates the config of a widget instance. + * Verifies ownership by userId before updating (T-05-01). + */ + async updateWidgetConfig( + id: string, + userId: string, + dto: UpdateWidgetConfigDto, + ) { + const widget = await this.prisma.widgetInstance.findUnique({ + where: { id }, + }); + + if (!widget || widget.userId !== userId) { + throw new NotFoundException( + `Widget with id '${id}' not found`, + ); + } + + // Merge existing config with new config + const mergedConfig = { + ...(widget.config as Record), + ...dto.config, + }; + + return this.prisma.widgetInstance.update({ + where: { id }, + data: { config: mergedConfig as unknown as Prisma.InputJsonValue }, + }); + } + + /** + * Removes a widget instance. + * Verifies ownership by userId before deleting (T-05-01). + */ + async removeWidget(id: string, userId: string) { + const widget = await this.prisma.widgetInstance.findUnique({ + where: { id }, + }); + + if (!widget || widget.userId !== userId) { + throw new NotFoundException( + `Widget with id '${id}' not found`, + ); + } + + return this.prisma.widgetInstance.delete({ + where: { id }, + }); + } +} diff --git a/apps/api/src/dashboard/dto/create-widget.dto.ts b/apps/api/src/dashboard/dto/create-widget.dto.ts new file mode 100644 index 0000000..7f95a48 --- /dev/null +++ b/apps/api/src/dashboard/dto/create-widget.dto.ts @@ -0,0 +1,16 @@ +import { IsIn, IsObject, IsOptional, IsString } from 'class-validator'; + +/** + * DTO for creating a new widget instance on a user's dashboard. + * widgetType must be one of the four supported types. + * config is optional and defaults to {} on the model. + */ +export class CreateWidgetDto { + @IsString() + @IsIn(['clock', 'search', 'calendar', 'note']) + widgetType!: string; + + @IsOptional() + @IsObject() + config?: Record; +} diff --git a/apps/api/src/dashboard/dto/save-layout.dto.ts b/apps/api/src/dashboard/dto/save-layout.dto.ts new file mode 100644 index 0000000..3c2f847 --- /dev/null +++ b/apps/api/src/dashboard/dto/save-layout.dto.ts @@ -0,0 +1,11 @@ +import { IsObject } from 'class-validator'; + +/** + * DTO for saving/updating a user's dashboard layout. + * The layouts object contains responsive breakpoint layouts + * (lg, md, sm, xs, xxs) as managed by react-grid-layout. + */ +export class SaveLayoutDto { + @IsObject() + layouts!: Record; +} diff --git a/apps/api/src/dashboard/dto/update-widget-config.dto.ts b/apps/api/src/dashboard/dto/update-widget-config.dto.ts new file mode 100644 index 0000000..bc1e7cb --- /dev/null +++ b/apps/api/src/dashboard/dto/update-widget-config.dto.ts @@ -0,0 +1,10 @@ +import { IsObject } from 'class-validator'; + +/** + * DTO for updating a widget instance's configuration. + * Config is merged server-side (partial update). + */ +export class UpdateWidgetConfigDto { + @IsObject() + config!: Record; +}