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