Bluewoo HRMS
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.ts

Widget 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

  1. Widget Registry: Centralized widget definitions with permissions and configuration schemas
  2. Default Dashboard: Auto-created with sensible defaults for new users
  3. Layout Management: Batch update widget positions for drag-and-drop
  4. Dashboard Sharing: Share dashboards with other users
  5. Dashboard Duplication: Copy dashboards to customize
  6. Permission-Based Widgets: Widgets filtered based on user permissions