Commit a791a680 authored by Administrator's avatar Administrator

Update 7 files via Son of Anton

parent 1a902ad7
...@@ -55,6 +55,9 @@ import { MeetingsModule } from './modules/meetings/meetings.module'; ...@@ -55,6 +55,9 @@ import { MeetingsModule } from './modules/meetings/meetings.module';
// ─── Phase 2D: Reports & Daily Operations ─────────────────── // ─── Phase 2D: Reports & Daily Operations ───────────────────
import { ReportsModule } from './modules/reports/reports.module'; import { ReportsModule } from './modules/reports/reports.module';
// ─── Phase 3A: Admin & Intelligence ─────────────────────────
import { AnalyticsModule } from './modules/analytics/analytics.module';
import { JwtAuthGuard } from './common/guards/jwt-auth.guard'; import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
import { RolesGuard } from './common/guards/roles.guard'; import { RolesGuard } from './common/guards/roles.guard';
import { TransformInterceptor } from './common/interceptors/transform.interceptor'; import { TransformInterceptor } from './common/interceptors/transform.interceptor';
...@@ -108,6 +111,8 @@ import { RateLimitMiddleware } from './common/middleware/rate-limit.middleware'; ...@@ -108,6 +111,8 @@ import { RateLimitMiddleware } from './common/middleware/rate-limit.middleware';
MeetingsModule, MeetingsModule,
// Phase 2D // Phase 2D
ReportsModule, ReportsModule,
// Phase 3A
AnalyticsModule,
], ],
providers: [ providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard }, { provide: APP_GUARD, useClass: JwtAuthGuard },
......
import {
Controller,
Get,
Post,
Body,
Param,
Query,
Res,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { Response } from 'express';
import { AnalyticsService } from './analytics.service';
import { ReportBuilderService } from './report-builder.service';
import { DataExportService } from './data-export.service';
import { AnalyticsFilterDto, ReportBuilderQueryDto, ExportRequestDto } from './dto/analytics-filter.dto';
import { CurrentUser, RequestUser } from '../../common/decorators/current-user.decorator';
import { Roles } from '../../common/decorators/roles.decorator';
@Controller('analytics')
export class AnalyticsController {
constructor(
private readonly analyticsService: AnalyticsService,
private readonly reportBuilderService: ReportBuilderService,
private readonly dataExportService: DataExportService,
) {}
// ─── DASHBOARDS ──────────────────────────────────────────
@Get('dashboard')
async getDashboard(@CurrentUser() user: RequestUser) {
switch (user.role) {
case 'SUPER_ADMIN':
return this.analyticsService.getSuperAdminDashboard();
case 'ADMIN':
return this.analyticsService.getAdminDashboard();
case 'TEAM_LEAD':
return this.analyticsService.getProjectLeaderDashboard(user.id);
case 'CONTRACTOR':
return this.analyticsService.getContractorDashboard(user.id);
default:
return {};
}
}
@Get('dashboard/contractor')
async getContractorDashboard(@CurrentUser() user: RequestUser) {
return this.analyticsService.getContractorDashboard(user.id);
}
@Get('dashboard/contractor/:userId')
@Roles('SUPER_ADMIN', 'ADMIN')
async getContractorDashboardAdmin(@Param('userId') userId: string) {
return this.analyticsService.getContractorDashboard(userId);
}
@Get('dashboard/project-leader')
@Roles('SUPER_ADMIN', 'TEAM_LEAD')
async getProjectLeaderDashboard(@CurrentUser() user: RequestUser) {
return this.analyticsService.getProjectLeaderDashboard(user.id);
}
@Get('dashboard/admin')
@Roles('SUPER_ADMIN', 'ADMIN')
async getAdminDashboard() {
return this.analyticsService.getAdminDashboard();
}
@Get('dashboard/super-admin')
@Roles('SUPER_ADMIN')
async getSuperAdminDashboard() {
return this.analyticsService.getSuperAdminDashboard();
}
// ─── ANALYTICS ENDPOINTS ─────────────────────────────────
@Get('deductions')
@Roles('SUPER_ADMIN', 'ADMIN')
async getDeductionAnalytics(@Query() filter: AnalyticsFilterDto) {
return this.analyticsService.getDeductionAnalytics(filter);
}
@Get('tasks')
@Roles('SUPER_ADMIN', 'ADMIN', 'TEAM_LEAD')
async getTaskAnalytics(@Query() filter: AnalyticsFilterDto) {
return this.analyticsService.getTaskAnalytics(filter);
}
@Get('system-health')
@Roles('SUPER_ADMIN')
async getSystemHealth() {
return this.analyticsService.getSystemHealth();
}
// ─── CUSTOM REPORT BUILDER ───────────────────────────────
@Post('report-builder')
@Roles('SUPER_ADMIN', 'ADMIN', 'TEAM_LEAD')
@HttpCode(HttpStatus.OK)
async executeReportQuery(@Body() query: ReportBuilderQueryDto, @CurrentUser() user: RequestUser) {
return this.reportBuilderService.executeQuery(query, user);
}
// ─── DATA EXPORT ─────────────────────────────────────────
@Post('export')
@Roles('SUPER_ADMIN', 'ADMIN', 'TEAM_LEAD')
async exportData(
@Body() dto: ExportRequestDto,
@CurrentUser() user: RequestUser,
@Res() res: Response,
) {
const result = await this.dataExportService.exportData(dto, user);
const format = dto.format || 'CSV';
if (format === 'JSON') {
res.setHeader('Content-Type', 'application/json');
res.setHeader('Content-Disposition', `attachment; filename=${result.filename}.json`);
res.send(JSON.stringify(result.data, null, 2));
} else {
// CSV
const csv = this.convertToCSV(result.data);
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', `attachment; filename=${result.filename}.csv`);
res.send(csv);
}
}
@Get('export/contractor/:userId')
@Roles('SUPER_ADMIN')
async exportContractorPackage(
@Param('userId') userId: string,
@CurrentUser() user: RequestUser,
@Res() res: Response,
) {
const data = await this.dataExportService.exportContractorPackage(userId, user);
res.setHeader('Content-Type', 'application/json');
res.setHeader('Content-Disposition', `attachment; filename=contractor-${userId}-${new Date().toISOString().split('T')[0]}.json`);
res.send(JSON.stringify(data, null, 2));
}
private convertToCSV(data: any[]): string {
if (!data || data.length === 0) return '';
const flattenObject = (obj: any, prefix = ''): Record<string, any> => {
const flat: Record<string, any> = {};
for (const [key, value] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (value !== null && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)) {
Object.assign(flat, flattenObject(value, fullKey));
} else if (Array.isArray(value)) {
flat[fullKey] = value.map((v) => (typeof v === 'object' ? JSON.stringify(v) : v)).join('; ');
} else {
flat[fullKey] = value;
}
}
return flat;
};
const flatData = data.map((row) => flattenObject(row));
const allKeys = new Set<string>();
flatData.forEach((row) => Object.keys(row).forEach((k) => allKeys.add(k)));
const headers = Array.from(allKeys);
const escapeCSV = (val: any): string => {
if (val === null || val === undefined) return '';
const str = String(val);
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
};
const rows = [
headers.join(','),
...flatData.map((row) => headers.map((h) => escapeCSV(row[h])).join(',')),
];
return rows.join('\n');
}
}
\ No newline at end of file
import { Module } from '@nestjs/common';
import { AnalyticsController } from './analytics.controller';
import { AnalyticsService } from './analytics.service';
import { ReportBuilderService } from './report-builder.service';
import { DataExportService } from './data-export.service';
@Module({
controllers: [AnalyticsController],
providers: [AnalyticsService, ReportBuilderService, DataExportService],
exports: [AnalyticsService, ReportBuilderService, DataExportService],
})
export class AnalyticsModule {}
\ No newline at end of file
This diff is collapsed.
import {
Injectable,
ForbiddenException,
BadRequestException,
Logger,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { ExportRequestDto } from './dto/analytics-filter.dto';
import { RequestUser } from '../../common/decorators/current-user.decorator';
@Injectable()
export class DataExportService {
private readonly logger = new Logger(DataExportService.name);
constructor(private readonly prisma: PrismaService) {}
async exportData(dto: ExportRequestDto, currentUser: RequestUser): Promise<{ data: any[]; filename: string }> {
if (currentUser.role !== 'SUPER_ADMIN' && currentUser.role !== 'ADMIN') {
if (currentUser.role === 'TEAM_LEAD') {
const allowedEntities = ['CARDS'];
if (!allowedEntities.includes(dto.entityType)) {
throw new ForbiddenException('Project Leaders can only export card data for their boards');
}
} else {
throw new ForbiddenException('Insufficient permissions to export data');
}
}
const timestamp = new Date().toISOString().split('T')[0];
switch (dto.entityType) {
case 'CONTRACTORS':
return {
data: await this.exportContractors(dto),
filename: `contractors-${timestamp}`,
};
case 'CARDS':
return {
data: await this.exportCards(dto, currentUser),
filename: `cards-${timestamp}`,
};
case 'DEDUCTIONS':
return {
data: await this.exportDeductions(dto),
filename: `deductions-${timestamp}`,
};
case 'BOUNTIES':
return {
data: await this.exportBounties(dto),
filename: `bounties-${timestamp}`,
};
case 'EVALUATIONS':
return {
data: await this.exportEvaluations(dto),
filename: `evaluations-${timestamp}`,
};
case 'PAYROLL':
return {
data: await this.exportPayroll(dto),
filename: `payroll-${timestamp}`,
};
case 'ADJUSTMENTS':
return {
data: await this.exportAdjustments(dto),
filename: `adjustments-${timestamp}`,
};
case 'AUDIT_TRAIL':
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can export audit trail');
}
return {
data: await this.exportAuditTrail(dto),
filename: `audit-trail-${timestamp}`,
};
default:
throw new BadRequestException(`Unsupported entity type: ${dto.entityType}`);
}
}
async exportContractorPackage(userId: string, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can export full contractor data packages');
}
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: {
contracts: true,
sessions: { take: 50, orderBy: { createdAt: 'desc' } },
},
});
if (!user) throw new BadRequestException('Contractor not found');
const deductions = await this.prisma.deduction.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
});
const bounties = await this.prisma.bountyPayout.findMany({
where: { userId },
orderBy: { paidAt: 'desc' },
});
const evaluations = await this.prisma.evaluation.findMany({
where: { userId },
orderBy: [{ year: 'desc' }, { month: 'desc' }],
});
const pips = await this.prisma.pip.findMany({
where: { userId },
include: { checkIns: true },
});
const learningGoals = await this.prisma.learningGoal.findMany({
where: { userId },
});
const adjustments = await this.prisma.adjustment.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
});
const unavailability = await this.prisma.unavailability.findMany({
where: { userId },
orderBy: { startDate: 'desc' },
});
const payrollLines = await this.prisma.payrollLine.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
});
// Strip password hash from user data
const { passwordHash, ...safeUser } = user;
return {
exportDate: new Date().toISOString(),
contractor: safeUser,
deductions,
bounties,
evaluations,
pips,
learningGoals,
adjustments,
unavailability,
payrollLines,
};
}
private async exportContractors(dto: ExportRequestDto): Promise<any[]> {
const where: any = { role: 'CONTRACTOR', deletedAt: null };
if (dto.userId) where.id = dto.userId;
return this.prisma.user.findMany({
where,
select: {
id: true,
firstName: true,
lastName: true,
username: true,
email: true,
contractorType: true,
status: true,
actualSalaryPiasters: true,
baseSalaryPiasters: true,
currentStreak: true,
bestStreak: true,
createdAt: true,
activatedAt: true,
lastLoginAt: true,
},
orderBy: { createdAt: 'desc' },
take: 10000,
});
}
private async exportCards(dto: ExportRequestDto, currentUser: RequestUser): Promise<any[]> {
const where: any = { deletedAt: null };
if (dto.dateFrom || dto.dateTo) {
where.createdAt = {};
if (dto.dateFrom) where.createdAt.gte = new Date(dto.dateFrom);
if (dto.dateTo) where.createdAt.lte = new Date(dto.dateTo);
}
if (currentUser.role === 'TEAM_LEAD') {
const plBoards = await this.prisma.boardMember.findMany({
where: { userId: currentUser.id },
select: { boardId: true },
});
where.column = { boardId: { in: plBoards.map((b) => b.boardId) } };
}
return this.prisma.card.findMany({
where,
select: {
id: true,
cardNumber: true,
title: true,
priority: true,
dueDate: true,
completedAt: true,
bountyPiasters: true,
estimatedHours: true,
actualHours: true,
leadTimeHours: true,
cycleTimeHours: true,
frozenTimeHours: true,
isArchived: true,
createdAt: true,
column: { select: { name: true, type: true, board: { select: { name: true, key: true } } } },
assignees: { select: { firstName: true, lastName: true } },
},
orderBy: { createdAt: 'desc' },
take: 50000,
});
}
private async exportDeductions(dto: ExportRequestDto): Promise<any[]> {
const where: any = {};
if (dto.userId) where.userId = dto.userId;
if (dto.dateFrom || dto.dateTo) {
where.violationDate = {};
if (dto.dateFrom) where.violationDate.gte = new Date(dto.dateFrom);
if (dto.dateTo) where.violationDate.lte = new Date(dto.dateTo);
}
return this.prisma.deduction.findMany({
where,
include: {
user: { select: { firstName: true, lastName: true } },
initiatedBy: { select: { firstName: true, lastName: true } },
},
orderBy: { createdAt: 'desc' },
take: 50000,
});
}
private async exportBounties(dto: ExportRequestDto): Promise<any[]> {
const where: any = {};
if (dto.userId) where.userId = dto.userId;
if (dto.dateFrom || dto.dateTo) {
where.paidAt = {};
if (dto.dateFrom) where.paidAt.gte = new Date(dto.dateFrom);
if (dto.dateTo) where.paidAt.lte = new Date(dto.dateTo);
}
return this.prisma.bountyPayout.findMany({
where,
orderBy: { paidAt: 'desc' },
take: 50000,
});
}
private async exportEvaluations(dto: ExportRequestDto): Promise<any[]> {
const where: any = {};
if (dto.userId) where.userId = dto.userId;
return this.prisma.evaluation.findMany({
where,
include: { user: { select: { firstName: true, lastName: true } } },
orderBy: [{ year: 'desc' }, { month: 'desc' }],
take: 50000,
});
}
private async exportPayroll(dto: ExportRequestDto): Promise<any[]> {
return this.prisma.payroll.findMany({
orderBy: [{ year: 'desc' }, { month: 'desc' }],
include: {
lines: {
include: {
user: { select: { firstName: true, lastName: true } },
},
},
},
take: 1000,
});
}
private async exportAdjustments(dto: ExportRequestDto): Promise<any[]> {
const where: any = {};
if (dto.userId) where.userId = dto.userId;
if (dto.dateFrom || dto.dateTo) {
where.createdAt = {};
if (dto.dateFrom) where.createdAt.gte = new Date(dto.dateFrom);
if (dto.dateTo) where.createdAt.lte = new Date(dto.dateTo);
}
return this.prisma.adjustment.findMany({
where,
include: {
user: { select: { firstName: true, lastName: true } },
createdBy: { select: { firstName: true, lastName: true } },
},
orderBy: { createdAt: 'desc' },
take: 50000,
});
}
private async exportAuditTrail(dto: ExportRequestDto): Promise<any[]> {
const where: any = {};
if (dto.userId) where.userId = dto.userId;
if (dto.dateFrom || dto.dateTo) {
where.createdAt = {};
if (dto.dateFrom) where.createdAt.gte = new Date(dto.dateFrom);
if (dto.dateTo) where.createdAt.lte = new Date(dto.dateTo);
}
return this.prisma.auditTrail.findMany({
where,
include: {
user: { select: { firstName: true, lastName: true, username: true } },
},
orderBy: { createdAt: 'desc' },
take: 50000,
});
}
}
\ No newline at end of file
import { IsOptional, IsString, IsDateString, IsInt, IsArray } from 'class-validator';
import { Type } from 'class-transformer';
import { PaginationDto } from '../../../common/dto/pagination.dto';
export class AnalyticsFilterDto {
@IsOptional()
@IsDateString()
dateFrom?: string;
@IsOptional()
@IsDateString()
dateTo?: string;
@IsOptional()
@IsString()
userId?: string;
@IsOptional()
@IsString()
boardId?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
month?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
year?: number;
}
export class ReportBuilderQueryDto extends PaginationDto {
@IsString()
dataSource: string;
// CONTRACTORS, CARDS, DEDUCTIONS, BOUNTIES, EVALUATIONS, REPORTS, PAYROLL, ADJUSTMENTS, UNAVAILABILITY, LEARNING_GOALS
@IsOptional()
@IsArray()
@IsString({ each: true })
columns?: string[];
@IsOptional()
@IsString()
groupBy?: string;
@IsOptional()
@IsString()
aggregation?: string; // SUM, AVG, COUNT, MIN, MAX
@IsOptional()
@IsString()
aggregationField?: string;
@IsOptional()
@IsDateString()
dateFrom?: string;
@IsOptional()
@IsDateString()
dateTo?: string;
@IsOptional()
@IsString()
userId?: string;
@IsOptional()
@IsString()
boardId?: string;
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsString()
category?: string;
}
export class ExportRequestDto {
@IsString()
entityType: string;
// CONTRACTORS, CARDS, DEDUCTIONS, BOUNTIES, EVALUATIONS, REPORTS, PAYROLL, ADJUSTMENTS, AUDIT_TRAIL, ALL
@IsOptional()
@IsString()
format?: string; // CSV, JSON (default: CSV)
@IsOptional()
@IsDateString()
dateFrom?: string;
@IsOptional()
@IsDateString()
dateTo?: string;
@IsOptional()
@IsString()
userId?: string;
}
\ No newline at end of file
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment