AI Development GuideEntity References
Reference Implementation - Dashboard
Complete Dashboard module with widget registry and sharing
Reference Implementation: Dashboard Module
This reference provides a complete Dashboard module implementation with a widget registry system, configurable layouts, and sharing capabilities.
Module Structure
apps/api/src/modules/dashboards/
├── dashboards.module.ts
├── dashboards.controller.ts
├── dashboards.repository.ts
├── dto/
│ ├── create-dashboard.dto.ts
│ ├── update-dashboard.dto.ts
│ ├── widget.dto.ts
│ └── dashboard-response.dto.ts
├── widgets/
│ ├── widget-registry.ts
│ └── widget-types.ts
└── dashboards.controller.spec.tsWidget Types
// apps/api/src/modules/dashboards/widgets/widget-types.ts
export enum WidgetType {
// Stats Widgets
EMPLOYEE_COUNT = 'employee_count',
ACTIVE_REQUESTS = 'active_requests',
PENDING_APPROVALS = 'pending_approvals',
TIME_OFF_BALANCE = 'time_off_balance',
// Chart Widgets
HEADCOUNT_TREND = 'headcount_trend',
DEPARTMENT_BREAKDOWN = 'department_breakdown',
TIME_OFF_CALENDAR = 'time_off_calendar',
ATTENDANCE_CHART = 'attendance_chart',
// List Widgets
UPCOMING_BIRTHDAYS = 'upcoming_birthdays',
NEW_HIRES = 'new_hires',
ANNIVERSARIES = 'anniversaries',
RECENT_DOCUMENTS = 'recent_documents',
// Activity Widgets
TEAM_ACTIVITY = 'team_activity',
MY_TASKS = 'my_tasks',
ANNOUNCEMENTS = 'announcements',
// Quick Actions
QUICK_ACTIONS = 'quick_actions',
}
export interface WidgetConfig {
// Common config
refreshInterval?: number; // seconds
// Chart config
chartType?: 'bar' | 'line' | 'pie' | 'donut';
dateRange?: 'week' | 'month' | 'quarter' | 'year';
// List config
limit?: number;
// Filter config
departmentId?: string;
teamId?: string;
}
export interface WidgetDefinition {
type: WidgetType;
name: string;
description: string;
defaultWidth: number; // Grid columns (1-12)
defaultHeight: number; // Grid rows
minWidth: number;
minHeight: number;
maxWidth: number;
maxHeight: number;
category: 'stats' | 'charts' | 'lists' | 'activity' | 'actions';
permissions: string[]; // Required permissions to use this widget
configSchema: Record<string, any>; // JSON schema for widget config
}Widget Registry
// apps/api/src/modules/dashboards/widgets/widget-registry.ts
import { Injectable } from '@nestjs/common';
import { WidgetType, WidgetDefinition } from './widget-types';
@Injectable()
export class WidgetRegistry {
private widgets: Map<WidgetType, WidgetDefinition> = new Map();
constructor() {
this.registerDefaultWidgets();
}
private registerDefaultWidgets(): void {
// Stats Widgets
this.register({
type: WidgetType.EMPLOYEE_COUNT,
name: 'Employee Count',
description: 'Shows total active employees',
defaultWidth: 3,
defaultHeight: 2,
minWidth: 2,
minHeight: 2,
maxWidth: 4,
maxHeight: 3,
category: 'stats',
permissions: ['employees:read'],
configSchema: {
type: 'object',
properties: {
departmentId: { type: 'string', format: 'uuid' },
showTrend: { type: 'boolean', default: true },
},
},
});
this.register({
type: WidgetType.ACTIVE_REQUESTS,
name: 'Active Requests',
description: 'Shows pending time-off and other requests',
defaultWidth: 3,
defaultHeight: 2,
minWidth: 2,
minHeight: 2,
maxWidth: 4,
maxHeight: 3,
category: 'stats',
permissions: ['time-off:read'],
configSchema: {},
});
this.register({
type: WidgetType.PENDING_APPROVALS,
name: 'Pending Approvals',
description: 'Items waiting for your approval',
defaultWidth: 3,
defaultHeight: 2,
minWidth: 2,
minHeight: 2,
maxWidth: 4,
maxHeight: 3,
category: 'stats',
permissions: ['approvals:read'],
configSchema: {},
});
this.register({
type: WidgetType.TIME_OFF_BALANCE,
name: 'Time Off Balance',
description: 'Your remaining time-off balances',
defaultWidth: 4,
defaultHeight: 2,
minWidth: 3,
minHeight: 2,
maxWidth: 6,
maxHeight: 3,
category: 'stats',
permissions: ['time-off:read:own'],
configSchema: {},
});
// Chart Widgets
this.register({
type: WidgetType.HEADCOUNT_TREND,
name: 'Headcount Trend',
description: 'Employee count over time',
defaultWidth: 6,
defaultHeight: 4,
minWidth: 4,
minHeight: 3,
maxWidth: 12,
maxHeight: 6,
category: 'charts',
permissions: ['employees:read', 'reports:read'],
configSchema: {
type: 'object',
properties: {
dateRange: { type: 'string', enum: ['month', 'quarter', 'year'], default: 'year' },
departmentId: { type: 'string', format: 'uuid' },
},
},
});
this.register({
type: WidgetType.DEPARTMENT_BREAKDOWN,
name: 'Department Breakdown',
description: 'Employee distribution by department',
defaultWidth: 4,
defaultHeight: 4,
minWidth: 3,
minHeight: 3,
maxWidth: 6,
maxHeight: 6,
category: 'charts',
permissions: ['employees:read', 'departments:read'],
configSchema: {
type: 'object',
properties: {
chartType: { type: 'string', enum: ['pie', 'donut', 'bar'], default: 'donut' },
},
},
});
this.register({
type: WidgetType.TIME_OFF_CALENDAR,
name: 'Time Off Calendar',
description: 'Calendar view of team time off',
defaultWidth: 6,
defaultHeight: 4,
minWidth: 4,
minHeight: 3,
maxWidth: 12,
maxHeight: 6,
category: 'charts',
permissions: ['time-off:read'],
configSchema: {
type: 'object',
properties: {
teamId: { type: 'string', format: 'uuid' },
departmentId: { type: 'string', format: 'uuid' },
},
},
});
// List Widgets
this.register({
type: WidgetType.UPCOMING_BIRTHDAYS,
name: 'Upcoming Birthdays',
description: 'Employee birthdays in the next 30 days',
defaultWidth: 4,
defaultHeight: 4,
minWidth: 3,
minHeight: 3,
maxWidth: 6,
maxHeight: 8,
category: 'lists',
permissions: ['employees:read'],
configSchema: {
type: 'object',
properties: {
limit: { type: 'number', minimum: 5, maximum: 20, default: 10 },
daysAhead: { type: 'number', minimum: 7, maximum: 90, default: 30 },
},
},
});
this.register({
type: WidgetType.NEW_HIRES,
name: 'New Hires',
description: 'Recently joined employees',
defaultWidth: 4,
defaultHeight: 4,
minWidth: 3,
minHeight: 3,
maxWidth: 6,
maxHeight: 8,
category: 'lists',
permissions: ['employees:read'],
configSchema: {
type: 'object',
properties: {
limit: { type: 'number', minimum: 5, maximum: 20, default: 10 },
daysBack: { type: 'number', minimum: 7, maximum: 90, default: 30 },
},
},
});
this.register({
type: WidgetType.ANNIVERSARIES,
name: 'Work Anniversaries',
description: 'Upcoming work anniversaries',
defaultWidth: 4,
defaultHeight: 4,
minWidth: 3,
minHeight: 3,
maxWidth: 6,
maxHeight: 8,
category: 'lists',
permissions: ['employees:read'],
configSchema: {
type: 'object',
properties: {
limit: { type: 'number', minimum: 5, maximum: 20, default: 10 },
daysAhead: { type: 'number', minimum: 7, maximum: 90, default: 30 },
},
},
});
this.register({
type: WidgetType.RECENT_DOCUMENTS,
name: 'Recent Documents',
description: 'Recently uploaded documents',
defaultWidth: 4,
defaultHeight: 4,
minWidth: 3,
minHeight: 3,
maxWidth: 6,
maxHeight: 8,
category: 'lists',
permissions: ['documents:read'],
configSchema: {
type: 'object',
properties: {
limit: { type: 'number', minimum: 5, maximum: 20, default: 10 },
},
},
});
// Activity Widgets
this.register({
type: WidgetType.TEAM_ACTIVITY,
name: 'Team Activity',
description: 'Recent activity in your team',
defaultWidth: 6,
defaultHeight: 4,
minWidth: 4,
minHeight: 3,
maxWidth: 12,
maxHeight: 8,
category: 'activity',
permissions: ['team-feed:read'],
configSchema: {
type: 'object',
properties: {
limit: { type: 'number', minimum: 5, maximum: 50, default: 20 },
},
},
});
this.register({
type: WidgetType.MY_TASKS,
name: 'My Tasks',
description: 'Your pending tasks and to-dos',
defaultWidth: 4,
defaultHeight: 4,
minWidth: 3,
minHeight: 3,
maxWidth: 6,
maxHeight: 8,
category: 'activity',
permissions: ['tasks:read:own'],
configSchema: {
type: 'object',
properties: {
showCompleted: { type: 'boolean', default: false },
limit: { type: 'number', minimum: 5, maximum: 20, default: 10 },
},
},
});
this.register({
type: WidgetType.ANNOUNCEMENTS,
name: 'Announcements',
description: 'Company announcements',
defaultWidth: 6,
defaultHeight: 3,
minWidth: 4,
minHeight: 2,
maxWidth: 12,
maxHeight: 6,
category: 'activity',
permissions: ['announcements:read'],
configSchema: {
type: 'object',
properties: {
limit: { type: 'number', minimum: 3, maximum: 10, default: 5 },
},
},
});
// Quick Actions
this.register({
type: WidgetType.QUICK_ACTIONS,
name: 'Quick Actions',
description: 'Shortcuts to common actions',
defaultWidth: 3,
defaultHeight: 2,
minWidth: 2,
minHeight: 2,
maxWidth: 6,
maxHeight: 4,
category: 'actions',
permissions: [],
configSchema: {
type: 'object',
properties: {
actions: {
type: 'array',
items: { type: 'string' },
default: ['request_time_off', 'view_payslip', 'update_profile'],
},
},
},
});
}
register(widget: WidgetDefinition): void {
this.widgets.set(widget.type, widget);
}
get(type: WidgetType): WidgetDefinition | undefined {
return this.widgets.get(type);
}
getAll(): WidgetDefinition[] {
return Array.from(this.widgets.values());
}
getByCategory(category: string): WidgetDefinition[] {
return this.getAll().filter((w) => w.category === category);
}
getAvailableForUser(userPermissions: string[]): WidgetDefinition[] {
return this.getAll().filter((widget) => {
if (widget.permissions.length === 0) return true;
return widget.permissions.every((p) => userPermissions.includes(p));
});
}
}DTOs
Create Dashboard DTO
// apps/api/src/modules/dashboards/dto/create-dashboard.dto.ts
import { IsString, IsNotEmpty, IsBoolean, IsOptional, IsArray, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { CreateWidgetDto } from './widget.dto';
export class CreateDashboardDto {
@IsString()
@IsNotEmpty()
name: string;
@IsString()
@IsOptional()
description?: string;
@IsBoolean()
@IsOptional()
isDefault?: boolean;
@IsArray()
@ValidateNested({ each: true })
@Type(() => CreateWidgetDto)
@IsOptional()
widgets?: CreateWidgetDto[];
}Widget DTO
// apps/api/src/modules/dashboards/dto/widget.dto.ts
import { IsString, IsNotEmpty, IsNumber, IsOptional, IsObject, Min, Max } from 'class-validator';
import { WidgetType, WidgetConfig } from '../widgets/widget-types';
export class CreateWidgetDto {
@IsString()
@IsNotEmpty()
type: WidgetType;
@IsString()
@IsOptional()
title?: string;
@IsNumber()
@Min(0)
positionX: number;
@IsNumber()
@Min(0)
positionY: number;
@IsNumber()
@Min(1)
@Max(12)
width: number;
@IsNumber()
@Min(1)
@Max(12)
height: number;
@IsObject()
@IsOptional()
config?: WidgetConfig;
}
export class UpdateWidgetDto {
@IsString()
@IsOptional()
title?: string;
@IsNumber()
@Min(0)
@IsOptional()
positionX?: number;
@IsNumber()
@Min(0)
@IsOptional()
positionY?: number;
@IsNumber()
@Min(1)
@Max(12)
@IsOptional()
width?: number;
@IsNumber()
@Min(1)
@Max(12)
@IsOptional()
height?: number;
@IsObject()
@IsOptional()
config?: WidgetConfig;
}
export class UpdateLayoutDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => WidgetLayoutDto)
widgets: WidgetLayoutDto[];
}
export class WidgetLayoutDto {
@IsString()
@IsNotEmpty()
id: string;
@IsNumber()
@Min(0)
positionX: number;
@IsNumber()
@Min(0)
positionY: number;
@IsNumber()
@Min(1)
@Max(12)
width: number;
@IsNumber()
@Min(1)
@Max(12)
height: number;
}Dashboard Response DTO
// apps/api/src/modules/dashboards/dto/dashboard-response.dto.ts
export class WidgetResponseDto {
id: string;
type: string;
title: string | null;
positionX: number;
positionY: number;
width: number;
height: number;
config: Record<string, any>;
}
export class DashboardResponseDto {
id: string;
name: string;
description: string | null;
isDefault: boolean;
widgets: WidgetResponseDto[];
isOwner: boolean;
isShared: boolean;
createdAt: Date;
updatedAt: Date;
}
export class DashboardListItemDto {
id: string;
name: string;
description: string | null;
isDefault: boolean;
widgetCount: number;
isOwner: boolean;
isShared: boolean;
}Dashboard Repository
// apps/api/src/modules/dashboards/dashboards.repository.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { Prisma, Dashboard } from '@prisma/client';
@Injectable()
export class DashboardsRepository {
constructor(private readonly prisma: PrismaService) {}
async create(data: Prisma.DashboardCreateInput): Promise<Dashboard> {
return this.prisma.dashboard.create({
data,
include: {
widgets: { orderBy: [{ positionY: 'asc' }, { positionX: 'asc' }] },
},
});
}
async findById(id: string, tenantId: string): Promise<Dashboard | null> {
return this.prisma.dashboard.findFirst({
where: { id, tenantId },
include: {
widgets: { orderBy: [{ positionY: 'asc' }, { positionX: 'asc' }] },
shares: { include: { user: { select: { id: true, email: true } } } },
user: { select: { id: true, email: true } },
},
});
}
async findByUser(userId: string, tenantId: string): Promise<Dashboard[]> {
return this.prisma.dashboard.findMany({
where: {
tenantId,
OR: [
{ userId }, // User's own dashboards
{ shares: { some: { userId } } }, // Shared dashboards
],
},
include: {
widgets: true,
user: { select: { id: true, email: true } },
},
orderBy: [{ isDefault: 'desc' }, { name: 'asc' }],
});
}
async findDefault(userId: string, tenantId: string): Promise<Dashboard | null> {
return this.prisma.dashboard.findFirst({
where: {
tenantId,
userId,
isDefault: true,
},
include: {
widgets: { orderBy: [{ positionY: 'asc' }, { positionX: 'asc' }] },
},
});
}
async update(id: string, tenantId: string, data: Prisma.DashboardUpdateInput): Promise<Dashboard> {
return this.prisma.dashboard.update({
where: { id, tenantId },
data,
include: {
widgets: { orderBy: [{ positionY: 'asc' }, { positionX: 'asc' }] },
},
});
}
async delete(id: string, tenantId: string): Promise<void> {
await this.prisma.dashboard.delete({
where: { id, tenantId },
});
}
async setDefault(id: string, userId: string, tenantId: string): Promise<void> {
await this.prisma.$transaction([
// Unset current default
this.prisma.dashboard.updateMany({
where: { userId, tenantId, isDefault: true },
data: { isDefault: false },
}),
// Set new default
this.prisma.dashboard.update({
where: { id, tenantId },
data: { isDefault: true },
}),
]);
}
// Widget operations
async addWidget(dashboardId: string, data: Prisma.DashboardWidgetCreateWithoutDashboardInput): Promise<any> {
return this.prisma.dashboardWidget.create({
data: {
...data,
dashboard: { connect: { id: dashboardId } },
},
});
}
async updateWidget(widgetId: string, data: Prisma.DashboardWidgetUpdateInput): Promise<any> {
return this.prisma.dashboardWidget.update({
where: { id: widgetId },
data,
});
}
async removeWidget(widgetId: string): Promise<void> {
await this.prisma.dashboardWidget.delete({
where: { id: widgetId },
});
}
async updateLayout(dashboardId: string, widgets: { id: string; positionX: number; positionY: number; width: number; height: number }[]): Promise<void> {
await this.prisma.$transaction(
widgets.map((w) =>
this.prisma.dashboardWidget.update({
where: { id: w.id, dashboardId },
data: {
positionX: w.positionX,
positionY: w.positionY,
width: w.width,
height: w.height,
},
}),
),
);
}
// Sharing operations
async share(dashboardId: string, userId: string, sharedById: string): Promise<void> {
await this.prisma.dashboardShare.upsert({
where: {
dashboardId_userId: { dashboardId, userId },
},
create: {
dashboardId,
userId,
sharedById,
},
update: {},
});
}
async unshare(dashboardId: string, userId: string): Promise<void> {
await this.prisma.dashboardShare.delete({
where: {
dashboardId_userId: { dashboardId, userId },
},
});
}
async getShares(dashboardId: string): Promise<any[]> {
return this.prisma.dashboardShare.findMany({
where: { dashboardId },
include: {
user: { select: { id: true, email: true } },
sharedBy: { select: { id: true, email: true } },
},
});
}
async hasAccess(dashboardId: string, userId: string): Promise<boolean> {
const dashboard = await this.prisma.dashboard.findFirst({
where: {
id: dashboardId,
OR: [
{ userId },
{ shares: { some: { userId } } },
],
},
});
return !!dashboard;
}
// Duplicate dashboard
async duplicate(id: string, userId: string, tenantId: string, name: string): Promise<Dashboard> {
const original = await this.findById(id, tenantId);
if (!original) throw new Error('Dashboard not found');
return this.prisma.dashboard.create({
data: {
name,
description: original.description,
isDefault: false,
tenant: { connect: { id: tenantId } },
user: { connect: { id: userId } },
widgets: {
create: original.widgets.map((w: any) => ({
type: w.type,
title: w.title,
positionX: w.positionX,
positionY: w.positionY,
width: w.width,
height: w.height,
config: w.config,
})),
},
},
include: {
widgets: true,
},
});
}
}Dashboard Module
// apps/api/src/modules/dashboards/dashboards.module.ts
import { Module } from '@nestjs/common';
import { DashboardsController } from './dashboards.controller';
import { DashboardsRepository } from './dashboards.repository';
import { WidgetRegistry } from './widgets/widget-registry';
@Module({
controllers: [DashboardsController],
providers: [DashboardsRepository, WidgetRegistry],
exports: [DashboardsRepository, WidgetRegistry],
})
export class DashboardsModule {}Dashboard Controller
// apps/api/src/modules/dashboards/dashboards.controller.ts
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
ParseUUIDPipe,
HttpCode,
HttpStatus,
ForbiddenException,
NotFoundException,
} from '@nestjs/common';
import { DashboardsRepository } from './dashboards.repository';
import { WidgetRegistry } from './widgets/widget-registry';
import { TenantGuard } from '../common/guards';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { CreateDashboardDto } from './dto/create-dashboard.dto';
import { CreateWidgetDto, UpdateWidgetDto, UpdateLayoutDto } from './dto/widget.dto';
@Controller('dashboards')
@UseGuards(TenantGuard)
export class DashboardsController {
constructor(
private readonly repository: DashboardsRepository,
private readonly widgetRegistry: WidgetRegistry,
) {}
@Get('widgets')
async getAvailableWidgets(@CurrentUser() user: any) {
const widgets = this.widgetRegistry.getAvailableForUser(user.permissions || []);
return {
data: widgets.map((w) => ({
type: w.type,
name: w.name,
description: w.description,
category: w.category,
defaultWidth: w.defaultWidth,
defaultHeight: w.defaultHeight,
minWidth: w.minWidth,
minHeight: w.minHeight,
maxWidth: w.maxWidth,
maxHeight: w.maxHeight,
configSchema: w.configSchema,
})),
};
}
@Post()
async create(@CurrentUser() user: any, @Body() dto: CreateDashboardDto) {
const dashboard = await this.repository.create({
name: dto.name,
description: dto.description,
isDefault: dto.isDefault ?? false,
tenant: { connect: { id: user.tenantId } },
user: { connect: { id: user.id } },
widgets: dto.widgets
? {
create: dto.widgets.map((w) => ({
type: w.type,
title: w.title,
positionX: w.positionX,
positionY: w.positionY,
width: w.width,
height: w.height,
config: w.config ?? {},
})),
}
: undefined,
});
if (dto.isDefault) {
await this.repository.setDefault(dashboard.id, user.id, user.tenantId);
}
return dashboard;
}
@Get()
async findAll(@CurrentUser() user: any) {
const dashboards = await this.repository.findByUser(user.id, user.tenantId);
return {
data: dashboards.map((d: any) => ({
id: d.id,
name: d.name,
description: d.description,
isDefault: d.isDefault,
widgetCount: d.widgets.length,
isOwner: d.userId === user.id,
isShared: d.userId !== user.id,
})),
};
}
@Get('default')
async findDefault(@CurrentUser() user: any) {
const dashboard = await this.repository.findDefault(user.id, user.tenantId);
if (!dashboard) {
// Create default dashboard if none exists
return this.create(user, {
name: 'My Dashboard',
description: 'Your personal dashboard',
isDefault: true,
widgets: [
{ type: 'employee_count' as any, positionX: 0, positionY: 0, width: 3, height: 2 },
{ type: 'active_requests' as any, positionX: 3, positionY: 0, width: 3, height: 2 },
{ type: 'pending_approvals' as any, positionX: 6, positionY: 0, width: 3, height: 2 },
{ type: 'time_off_balance' as any, positionX: 9, positionY: 0, width: 3, height: 2 },
{ type: 'upcoming_birthdays' as any, positionX: 0, positionY: 2, width: 4, height: 4 },
{ type: 'team_activity' as any, positionX: 4, positionY: 2, width: 8, height: 4 },
],
});
}
return this.enrichDashboard(dashboard, user.id);
}
@Get(':id')
async findOne(@CurrentUser() user: any, @Param('id', ParseUUIDPipe) id: string) {
const hasAccess = await this.repository.hasAccess(id, user.id);
if (!hasAccess) {
throw new ForbiddenException('You do not have access to this dashboard');
}
const dashboard = await this.repository.findById(id, user.tenantId);
if (!dashboard) {
throw new NotFoundException('Dashboard not found');
}
return this.enrichDashboard(dashboard, user.id);
}
@Put(':id')
async update(
@CurrentUser() user: any,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: Partial<CreateDashboardDto>,
) {
await this.checkOwnership(id, user);
const { widgets, ...updateData } = dto;
const dashboard = await this.repository.update(id, user.tenantId, updateData);
if (dto.isDefault) {
await this.repository.setDefault(id, user.id, user.tenantId);
}
return dashboard;
}
@Put(':id/layout')
async updateLayout(
@CurrentUser() user: any,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateLayoutDto,
) {
await this.checkOwnership(id, user);
await this.repository.updateLayout(id, dto.widgets);
return this.repository.findById(id, user.tenantId);
}
@Post(':id/widgets')
async addWidget(
@CurrentUser() user: any,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: CreateWidgetDto,
) {
await this.checkOwnership(id, user);
// Validate widget type
const widgetDef = this.widgetRegistry.get(dto.type);
if (!widgetDef) {
throw new NotFoundException('Widget type not found');
}
// Check user has required permissions
if (widgetDef.permissions.length > 0) {
const userPermissions = user.permissions || [];
const hasPermission = widgetDef.permissions.every((p) => userPermissions.includes(p));
if (!hasPermission) {
throw new ForbiddenException('You do not have permission to use this widget');
}
}
return this.repository.addWidget(id, {
type: dto.type,
title: dto.title,
positionX: dto.positionX,
positionY: dto.positionY,
width: dto.width,
height: dto.height,
config: dto.config ?? {},
});
}
@Put(':id/widgets/:widgetId')
async updateWidget(
@CurrentUser() user: any,
@Param('id', ParseUUIDPipe) id: string,
@Param('widgetId', ParseUUIDPipe) widgetId: string,
@Body() dto: UpdateWidgetDto,
) {
await this.checkOwnership(id, user);
return this.repository.updateWidget(widgetId, dto);
}
@Delete(':id/widgets/:widgetId')
@HttpCode(HttpStatus.NO_CONTENT)
async removeWidget(
@CurrentUser() user: any,
@Param('id', ParseUUIDPipe) id: string,
@Param('widgetId', ParseUUIDPipe) widgetId: string,
) {
await this.checkOwnership(id, user);
await this.repository.removeWidget(widgetId);
}
@Post(':id/duplicate')
async duplicate(
@CurrentUser() user: any,
@Param('id', ParseUUIDPipe) id: string,
@Body('name') name?: string,
) {
const hasAccess = await this.repository.hasAccess(id, user.id);
if (!hasAccess) {
throw new ForbiddenException('You do not have access to this dashboard');
}
const original = await this.repository.findById(id, user.tenantId);
const newName = name || `${original?.name} (Copy)`;
return this.repository.duplicate(id, user.id, user.tenantId, newName);
}
@Post(':id/share')
async share(
@CurrentUser() user: any,
@Param('id', ParseUUIDPipe) id: string,
@Body('userId', ParseUUIDPipe) targetUserId: string,
) {
await this.checkOwnership(id, user);
await this.repository.share(id, targetUserId, user.id);
return { success: true };
}
@Delete(':id/share/:userId')
@HttpCode(HttpStatus.NO_CONTENT)
async unshare(
@CurrentUser() user: any,
@Param('id', ParseUUIDPipe) id: string,
@Param('userId', ParseUUIDPipe) targetUserId: string,
) {
await this.checkOwnership(id, user);
await this.repository.unshare(id, targetUserId);
}
@Get(':id/shares')
async getShares(@CurrentUser() user: any, @Param('id', ParseUUIDPipe) id: string) {
await this.checkOwnership(id, user);
return { data: await this.repository.getShares(id) };
}
@Post(':id/set-default')
async setDefault(@CurrentUser() user: any, @Param('id', ParseUUIDPipe) id: string) {
await this.checkOwnership(id, user);
await this.repository.setDefault(id, user.id, user.tenantId);
return { success: true };
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async delete(@CurrentUser() user: any, @Param('id', ParseUUIDPipe) id: string) {
await this.checkOwnership(id, user);
await this.repository.delete(id, user.tenantId);
}
private async checkOwnership(dashboardId: string, user: any): Promise<void> {
const dashboard = await this.repository.findById(dashboardId, user.tenantId);
if (!dashboard) {
throw new NotFoundException('Dashboard not found');
}
if ((dashboard as any).userId !== user.id) {
throw new ForbiddenException('You can only modify your own dashboards');
}
}
private enrichDashboard(dashboard: any, userId: string) {
return {
...dashboard,
isOwner: dashboard.userId === userId,
isShared: dashboard.userId !== userId,
};
}
}Unit Tests
// apps/api/src/modules/dashboards/dashboards.controller.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { DashboardsController } from './dashboards.controller';
import { DashboardsRepository } from './dashboards.repository';
import { WidgetRegistry } from './widgets/widget-registry';
import { ForbiddenException, NotFoundException } from '@nestjs/common';
describe('DashboardsController', () => {
let controller: DashboardsController;
let repository: jest.Mocked<DashboardsRepository>;
let widgetRegistry: WidgetRegistry;
const mockUser = {
id: 'user-1',
tenantId: 'tenant-1',
permissions: ['employees:read', 'time-off:read'],
};
const mockDashboard = {
id: 'dashboard-1',
name: 'My Dashboard',
description: null,
isDefault: true,
userId: 'user-1',
tenantId: 'tenant-1',
widgets: [],
createdAt: new Date(),
updatedAt: new Date(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [DashboardsController],
providers: [
{
provide: DashboardsRepository,
useValue: {
create: jest.fn(),
findById: jest.fn(),
findByUser: jest.fn(),
findDefault: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
setDefault: jest.fn(),
addWidget: jest.fn(),
updateWidget: jest.fn(),
removeWidget: jest.fn(),
updateLayout: jest.fn(),
share: jest.fn(),
unshare: jest.fn(),
getShares: jest.fn(),
hasAccess: jest.fn(),
duplicate: jest.fn(),
},
},
WidgetRegistry,
],
}).compile();
controller = module.get<DashboardsController>(DashboardsController);
repository = module.get(DashboardsRepository);
widgetRegistry = module.get(WidgetRegistry);
});
describe('getAvailableWidgets', () => {
it('should return widgets user has permission for', async () => {
const result = await controller.getAvailableWidgets(mockUser);
expect(result.data).toBeDefined();
expect(Array.isArray(result.data)).toBe(true);
// Should include employee_count (requires employees:read)
expect(result.data.some((w: any) => w.type === 'employee_count')).toBe(true);
});
});
describe('create', () => {
it('should create a new dashboard', async () => {
repository.create.mockResolvedValue(mockDashboard);
const result = await controller.create(mockUser, {
name: 'New Dashboard',
isDefault: false,
});
expect(repository.create).toHaveBeenCalled();
expect(result).toEqual(mockDashboard);
});
});
describe('findDefault', () => {
it('should return default dashboard', async () => {
repository.findDefault.mockResolvedValue(mockDashboard);
const result = await controller.findDefault(mockUser);
expect(result.isOwner).toBe(true);
});
it('should create default dashboard if none exists', async () => {
repository.findDefault.mockResolvedValue(null);
repository.create.mockResolvedValue(mockDashboard);
const result = await controller.findDefault(mockUser);
expect(repository.create).toHaveBeenCalled();
});
});
describe('findOne', () => {
it('should return dashboard when user has access', async () => {
repository.hasAccess.mockResolvedValue(true);
repository.findById.mockResolvedValue(mockDashboard);
const result = await controller.findOne(mockUser, 'dashboard-1');
expect(result.isOwner).toBe(true);
});
it('should throw when user lacks access', async () => {
repository.hasAccess.mockResolvedValue(false);
await expect(controller.findOne(mockUser, 'dashboard-1'))
.rejects.toThrow(ForbiddenException);
});
});
describe('update', () => {
it('should update dashboard when user is owner', async () => {
repository.findById.mockResolvedValue(mockDashboard);
repository.update.mockResolvedValue({ ...mockDashboard, name: 'Updated' });
const result = await controller.update(mockUser, 'dashboard-1', { name: 'Updated' });
expect(repository.update).toHaveBeenCalled();
});
it('should throw when user is not owner', async () => {
repository.findById.mockResolvedValue({ ...mockDashboard, userId: 'other-user' });
await expect(controller.update(mockUser, 'dashboard-1', { name: 'Updated' }))
.rejects.toThrow(ForbiddenException);
});
});
describe('addWidget', () => {
it('should add widget when type is valid', async () => {
repository.findById.mockResolvedValue(mockDashboard);
repository.addWidget.mockResolvedValue({ id: 'widget-1', type: 'employee_count' });
const result = await controller.addWidget(mockUser, 'dashboard-1', {
type: 'employee_count' as any,
positionX: 0,
positionY: 0,
width: 3,
height: 2,
});
expect(repository.addWidget).toHaveBeenCalled();
});
it('should throw when widget type not found', async () => {
repository.findById.mockResolvedValue(mockDashboard);
await expect(controller.addWidget(mockUser, 'dashboard-1', {
type: 'invalid_widget' as any,
positionX: 0,
positionY: 0,
width: 3,
height: 2,
})).rejects.toThrow(NotFoundException);
});
});
describe('duplicate', () => {
it('should duplicate dashboard', async () => {
repository.hasAccess.mockResolvedValue(true);
repository.findById.mockResolvedValue(mockDashboard);
repository.duplicate.mockResolvedValue({ ...mockDashboard, id: 'dashboard-2', name: 'Copy' });
const result = await controller.duplicate(mockUser, 'dashboard-1', 'Copy');
expect(repository.duplicate).toHaveBeenCalledWith('dashboard-1', 'user-1', 'tenant-1', 'Copy');
});
});
});Key Features
- Widget Registry: Centralized widget definitions with permissions and configuration schemas
- Default Dashboard: Auto-created with sensible defaults for new users
- Layout Management: Batch update widget positions for drag-and-drop
- Dashboard Sharing: Share dashboards with other users
- Dashboard Duplication: Copy dashboards to customize
- Permission-Based Widgets: Widgets filtered based on user permissions