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])
|
@@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])
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {}
|
||||||
@@ -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