feat(05-01): dashboard backend — Prisma models, CRUD API, module wiring

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 11:14:03 +02:00
parent f925fd3654
commit 950eebbc15
8 changed files with 306 additions and 0 deletions
+24
View File
@@ -110,3 +110,27 @@ model TenantModuleActivation {
@@unique([tenantId, moduleId]) @@unique([tenantId, moduleId])
@@index([tenantId]) @@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])
}
+2
View File
@@ -8,6 +8,7 @@ import { ForcePasswordChangeInterceptor } from './auth/interceptors/force-passwo
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
import { LdapModule } from './ldap/ldap.module'; import { LdapModule } from './ldap/ldap.module';
import { MailModule } from './mail/mail.module'; import { MailModule } from './mail/mail.module';
import { DashboardModule } from './dashboard/dashboard.module';
import { DomaincheckModule } from './domaincheck/domaincheck.module'; import { DomaincheckModule } from './domaincheck/domaincheck.module';
import { ModuleRegistryModule } from './module-registry/module-registry.module'; import { ModuleRegistryModule } from './module-registry/module-registry.module';
import { PrismaModule } from './prisma/prisma.module'; import { PrismaModule } from './prisma/prisma.module';
@@ -27,6 +28,7 @@ import { UserModule } from './user/user.module';
LdapModule, LdapModule,
ModuleRegistryModule, ModuleRegistryModule,
DomaincheckModule, DomaincheckModule,
DashboardModule,
], ],
providers: [ providers: [
// Global JWT guard: all routes require auth unless @Public() // Global JWT guard: all routes require auth unless @Public()
@@ -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);
}
}
@@ -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 {}
+129
View File
@@ -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<string, unknown>),
...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 },
});
}
}
@@ -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<string, unknown>;
}
@@ -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<string, unknown>;
}
@@ -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<string, unknown>;
}