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
+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 },
});
}
}