[TASK-005] feat: Implement reports backend module (entities, services, controllers)
- Add 12 TypeORM entities matching DDL 31-reports.sql: - ReportDefinition, ReportExecution, ReportSchedule - ReportRecipient, ScheduleExecution - Dashboard, DashboardWidget, WidgetQuery - CustomReport, DataModelEntity, DataModelField, DataModelRelationship - Add enums: ReportType, ExecutionStatus, ExportFormat, DeliveryMethod, WidgetType, ParamType, FilterOperator - Add DTOs for CRUD operations and filtering - Add services: - ReportsService: definitions, schedules, recipients, custom reports - ReportExecutionService: execute reports, history, cancellation - ReportSchedulerService: scheduled execution, delivery - DashboardsService: dashboards, widgets, queries - Add controllers: - ReportsController: full CRUD for definitions, schedules, executions - DashboardsController: dashboards, widgets, widget queries - Update module and routes with new structure - Maintain backwards compatibility with legacy service Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
5ee2023428
commit
6c6ce41343
351
src/modules/reports/controllers/dashboards.controller.ts
Normal file
351
src/modules/reports/controllers/dashboards.controller.ts
Normal file
@ -0,0 +1,351 @@
|
||||
import { Request, Response, NextFunction, Router } from 'express';
|
||||
import { DashboardsService } from '../services/dashboards.service';
|
||||
import {
|
||||
CreateDashboardDto,
|
||||
UpdateDashboardDto,
|
||||
CreateDashboardWidgetDto,
|
||||
UpdateDashboardWidgetDto,
|
||||
CreateWidgetQueryDto,
|
||||
UpdateWidgetQueryDto,
|
||||
UpdateWidgetPositionsDto,
|
||||
DashboardFiltersDto,
|
||||
} from '../dto';
|
||||
import { logger } from '../../../shared/utils/logger.js';
|
||||
|
||||
/**
|
||||
* DashboardsController
|
||||
*
|
||||
* REST API for managing dashboards, widgets, and widget queries.
|
||||
*/
|
||||
export class DashboardsController {
|
||||
public router: Router;
|
||||
|
||||
constructor(private readonly dashboardsService: DashboardsService) {
|
||||
this.router = Router();
|
||||
this.initializeRoutes();
|
||||
}
|
||||
|
||||
private initializeRoutes(): void {
|
||||
// Dashboards
|
||||
this.router.get('/', this.getDashboards.bind(this));
|
||||
this.router.get('/default', this.getDefaultDashboard.bind(this));
|
||||
this.router.get('/:id', this.getDashboardById.bind(this));
|
||||
this.router.get('/:id/data', this.getDashboardData.bind(this));
|
||||
this.router.post('/', this.createDashboard.bind(this));
|
||||
this.router.put('/:id', this.updateDashboard.bind(this));
|
||||
this.router.delete('/:id', this.deleteDashboard.bind(this));
|
||||
|
||||
// Widgets
|
||||
this.router.get('/:dashboardId/widgets', this.getWidgets.bind(this));
|
||||
this.router.post('/:dashboardId/widgets', this.createWidget.bind(this));
|
||||
this.router.put('/widgets/:id', this.updateWidget.bind(this));
|
||||
this.router.patch('/widgets/positions', this.updateWidgetPositions.bind(this));
|
||||
this.router.delete('/widgets/:id', this.deleteWidget.bind(this));
|
||||
|
||||
// Widget Queries
|
||||
this.router.post('/widgets/:widgetId/queries', this.createWidgetQuery.bind(this));
|
||||
this.router.put('/queries/:id', this.updateWidgetQuery.bind(this));
|
||||
this.router.delete('/queries/:id', this.deleteWidgetQuery.bind(this));
|
||||
this.router.post('/queries/:id/execute', this.executeWidgetQuery.bind(this));
|
||||
}
|
||||
|
||||
// ==================== DASHBOARDS ====================
|
||||
|
||||
private async getDashboards(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
const userId = req.headers['x-user-id'] as string;
|
||||
const { ownerId, isDefault, isPublic, isActive, search, page, limit } = req.query;
|
||||
|
||||
const filters: DashboardFiltersDto = {
|
||||
ownerId: ownerId as string,
|
||||
isDefault: isDefault !== undefined ? isDefault === 'true' : undefined,
|
||||
isPublic: isPublic !== undefined ? isPublic === 'true' : undefined,
|
||||
isActive: isActive !== undefined ? isActive === 'true' : undefined,
|
||||
search: search as string,
|
||||
page: page ? parseInt(page as string, 10) : undefined,
|
||||
limit: limit ? parseInt(limit as string, 10) : undefined,
|
||||
};
|
||||
|
||||
const { data, total } = await this.dashboardsService.findAllDashboards(tenantId, userId, filters);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data,
|
||||
pagination: {
|
||||
page: filters.page || 1,
|
||||
limit: filters.limit || 20,
|
||||
total,
|
||||
totalPages: Math.ceil(total / (filters.limit || 20)),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async getDefaultDashboard(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
const userId = req.headers['x-user-id'] as string;
|
||||
|
||||
const dashboard = await this.dashboardsService.getDefaultDashboard(tenantId, userId);
|
||||
|
||||
if (!dashboard) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'No default dashboard found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: dashboard,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async getDashboardById(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
const userId = req.headers['x-user-id'] as string;
|
||||
|
||||
const dashboard = await this.dashboardsService.findDashboardById(id, tenantId, userId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: dashboard,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async getDashboardData(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
const userId = req.headers['x-user-id'] as string;
|
||||
|
||||
const data = await this.dashboardsService.getDashboardData(id, tenantId, userId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async createDashboard(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
const userId = req.headers['x-user-id'] as string;
|
||||
const dto: CreateDashboardDto = req.body;
|
||||
|
||||
const dashboard = await this.dashboardsService.createDashboard(dto, tenantId, userId);
|
||||
|
||||
logger.info('Dashboard created via API', { id: dashboard.id, userId });
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: dashboard,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async updateDashboard(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
const userId = req.headers['x-user-id'] as string;
|
||||
const dto: UpdateDashboardDto = req.body;
|
||||
|
||||
const dashboard = await this.dashboardsService.updateDashboard(id, dto, tenantId, userId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: dashboard,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteDashboard(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
const userId = req.headers['x-user-id'] as string;
|
||||
|
||||
await this.dashboardsService.deleteDashboard(id, tenantId, userId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Dashboard deleted',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== WIDGETS ====================
|
||||
|
||||
private async getWidgets(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const { dashboardId } = req.params;
|
||||
|
||||
const widgets = await this.dashboardsService.findWidgetsByDashboard(dashboardId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: widgets,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async createWidget(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const { dashboardId } = req.params;
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
const userId = req.headers['x-user-id'] as string;
|
||||
const dto: CreateDashboardWidgetDto = { ...req.body, dashboardId };
|
||||
|
||||
const widget = await this.dashboardsService.createWidget(dto, tenantId, userId);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: widget,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async updateWidget(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
const userId = req.headers['x-user-id'] as string;
|
||||
const dto: UpdateDashboardWidgetDto = req.body;
|
||||
|
||||
const widget = await this.dashboardsService.updateWidget(id, dto, tenantId, userId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: widget,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async updateWidgetPositions(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
const userId = req.headers['x-user-id'] as string;
|
||||
const dto: UpdateWidgetPositionsDto = req.body;
|
||||
|
||||
await this.dashboardsService.updateWidgetPositions(dto, tenantId, userId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Widget positions updated',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteWidget(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
const userId = req.headers['x-user-id'] as string;
|
||||
|
||||
await this.dashboardsService.deleteWidget(id, tenantId, userId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Widget deleted',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== WIDGET QUERIES ====================
|
||||
|
||||
private async createWidgetQuery(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const { widgetId } = req.params;
|
||||
const dto: CreateWidgetQueryDto = { ...req.body, widgetId };
|
||||
|
||||
const query = await this.dashboardsService.createWidgetQuery(dto);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: query,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async updateWidgetQuery(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const dto: UpdateWidgetQueryDto = req.body;
|
||||
|
||||
const query = await this.dashboardsService.updateWidgetQuery(id, dto);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: query,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteWidgetQuery(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
await this.dashboardsService.deleteWidgetQuery(id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Widget query deleted',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async executeWidgetQuery(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
const parameters = req.body.parameters || {};
|
||||
|
||||
const result = await this.dashboardsService.executeWidgetQuery(id, tenantId, parameters);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,10 +1,15 @@
|
||||
import { Request, Response, NextFunction, Router } from 'express';
|
||||
import { ReportsService } from '../services';
|
||||
// Re-export new controllers
|
||||
export { ReportsController } from './reports.controller';
|
||||
export { DashboardsController } from './dashboards.controller';
|
||||
|
||||
export class ReportsController {
|
||||
// Legacy controller (kept for backwards compatibility)
|
||||
import { Request, Response, NextFunction, Router } from 'express';
|
||||
import { LegacyReportsService } from '../services';
|
||||
|
||||
export class LegacyReportsController {
|
||||
public router: Router;
|
||||
|
||||
constructor(private readonly reportsService: ReportsService) {
|
||||
constructor(private readonly reportsService: LegacyReportsService) {
|
||||
this.router = Router();
|
||||
|
||||
// Sales Reports
|
||||
|
||||
597
src/modules/reports/controllers/reports.controller.ts
Normal file
597
src/modules/reports/controllers/reports.controller.ts
Normal file
@ -0,0 +1,597 @@
|
||||
import { Request, Response, NextFunction, Router } from 'express';
|
||||
import { ReportsService } from '../services/reports.service';
|
||||
import { ReportExecutionService } from '../services/report-execution.service';
|
||||
import { ReportSchedulerService } from '../services/report-scheduler.service';
|
||||
import {
|
||||
CreateReportDefinitionDto,
|
||||
UpdateReportDefinitionDto,
|
||||
CreateReportScheduleDto,
|
||||
UpdateReportScheduleDto,
|
||||
CreateReportRecipientDto,
|
||||
CreateCustomReportDto,
|
||||
UpdateCustomReportDto,
|
||||
ExecuteReportDto,
|
||||
ReportDefinitionFiltersDto,
|
||||
ReportExecutionFiltersDto,
|
||||
ReportScheduleFiltersDto,
|
||||
CustomReportFiltersDto,
|
||||
} from '../dto';
|
||||
import { logger } from '../../../shared/utils/logger.js';
|
||||
|
||||
/**
|
||||
* ReportsController
|
||||
*
|
||||
* REST API for managing reports, definitions, executions, schedules, and custom reports.
|
||||
*/
|
||||
export class ReportsController {
|
||||
public router: Router;
|
||||
|
||||
constructor(
|
||||
private readonly reportsService: ReportsService,
|
||||
private readonly executionService: ReportExecutionService,
|
||||
private readonly schedulerService: ReportSchedulerService
|
||||
) {
|
||||
this.router = Router();
|
||||
this.initializeRoutes();
|
||||
}
|
||||
|
||||
private initializeRoutes(): void {
|
||||
// Definitions
|
||||
this.router.get('/definitions', this.getDefinitions.bind(this));
|
||||
this.router.get('/definitions/categories', this.getCategories.bind(this));
|
||||
this.router.get('/definitions/:id', this.getDefinitionById.bind(this));
|
||||
this.router.post('/definitions', this.createDefinition.bind(this));
|
||||
this.router.put('/definitions/:id', this.updateDefinition.bind(this));
|
||||
this.router.delete('/definitions/:id', this.deleteDefinition.bind(this));
|
||||
|
||||
// Executions
|
||||
this.router.post('/execute', this.executeReport.bind(this));
|
||||
this.router.get('/executions', this.getExecutions.bind(this));
|
||||
this.router.get('/executions/:id', this.getExecutionById.bind(this));
|
||||
this.router.post('/executions/:id/cancel', this.cancelExecution.bind(this));
|
||||
|
||||
// Schedules
|
||||
this.router.get('/schedules', this.getSchedules.bind(this));
|
||||
this.router.get('/schedules/:id', this.getScheduleById.bind(this));
|
||||
this.router.post('/schedules', this.createSchedule.bind(this));
|
||||
this.router.put('/schedules/:id', this.updateSchedule.bind(this));
|
||||
this.router.patch('/schedules/:id/toggle', this.toggleSchedule.bind(this));
|
||||
this.router.delete('/schedules/:id', this.deleteSchedule.bind(this));
|
||||
this.router.post('/schedules/:id/trigger', this.triggerSchedule.bind(this));
|
||||
this.router.get('/schedules/:id/history', this.getScheduleHistory.bind(this));
|
||||
|
||||
// Recipients
|
||||
this.router.get('/schedules/:scheduleId/recipients', this.getRecipients.bind(this));
|
||||
this.router.post('/schedules/:scheduleId/recipients', this.addRecipient.bind(this));
|
||||
this.router.delete('/recipients/:id', this.removeRecipient.bind(this));
|
||||
|
||||
// Custom Reports
|
||||
this.router.get('/custom', this.getCustomReports.bind(this));
|
||||
this.router.get('/custom/:id', this.getCustomReportById.bind(this));
|
||||
this.router.post('/custom', this.createCustomReport.bind(this));
|
||||
this.router.put('/custom/:id', this.updateCustomReport.bind(this));
|
||||
this.router.patch('/custom/:id/favorite', this.toggleFavorite.bind(this));
|
||||
this.router.delete('/custom/:id', this.deleteCustomReport.bind(this));
|
||||
}
|
||||
|
||||
// ==================== DEFINITIONS ====================
|
||||
|
||||
private async getDefinitions(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
const { reportType, category, isPublic, isActive, search, page, limit } = req.query;
|
||||
|
||||
const filters: ReportDefinitionFiltersDto = {
|
||||
reportType: reportType as any,
|
||||
category: category as string,
|
||||
isPublic: isPublic !== undefined ? isPublic === 'true' : undefined,
|
||||
isActive: isActive !== undefined ? isActive === 'true' : undefined,
|
||||
search: search as string,
|
||||
page: page ? parseInt(page as string, 10) : undefined,
|
||||
limit: limit ? parseInt(limit as string, 10) : undefined,
|
||||
};
|
||||
|
||||
const { data, total } = await this.reportsService.findAllDefinitions(tenantId, filters);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data,
|
||||
pagination: {
|
||||
page: filters.page || 1,
|
||||
limit: filters.limit || 20,
|
||||
total,
|
||||
totalPages: Math.ceil(total / (filters.limit || 20)),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async getCategories(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
const categories = await this.reportsService.getCategories(tenantId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: categories,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async getDefinitionById(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
|
||||
const definition = await this.reportsService.findDefinitionById(id, tenantId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: definition,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async createDefinition(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
const userId = req.headers['x-user-id'] as string;
|
||||
const dto: CreateReportDefinitionDto = req.body;
|
||||
|
||||
const definition = await this.reportsService.createDefinition(dto, tenantId, userId);
|
||||
|
||||
logger.info('Report definition created via API', { id: definition.id, userId });
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: definition,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async updateDefinition(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
const dto: UpdateReportDefinitionDto = req.body;
|
||||
|
||||
const definition = await this.reportsService.updateDefinition(id, dto, tenantId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: definition,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteDefinition(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
|
||||
await this.reportsService.deleteDefinition(id, tenantId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Report definition deleted',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== EXECUTIONS ====================
|
||||
|
||||
private async executeReport(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
const userId = req.headers['x-user-id'] as string;
|
||||
const dto: ExecuteReportDto = req.body;
|
||||
|
||||
const execution = await this.executionService.executeReport(dto, tenantId, userId);
|
||||
|
||||
res.status(202).json({
|
||||
success: true,
|
||||
message: 'Report execution started',
|
||||
data: execution,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async getExecutions(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
const { reportDefinitionId, status, executedBy, dateFrom, dateTo, page, limit } = req.query;
|
||||
|
||||
const filters: ReportExecutionFiltersDto = {
|
||||
reportDefinitionId: reportDefinitionId as string,
|
||||
status: status as any,
|
||||
executedBy: executedBy as string,
|
||||
dateFrom: dateFrom as string,
|
||||
dateTo: dateTo as string,
|
||||
page: page ? parseInt(page as string, 10) : undefined,
|
||||
limit: limit ? parseInt(limit as string, 10) : undefined,
|
||||
};
|
||||
|
||||
const { data, total } = await this.executionService.findAll(tenantId, filters);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data,
|
||||
pagination: {
|
||||
page: filters.page || 1,
|
||||
limit: filters.limit || 20,
|
||||
total,
|
||||
totalPages: Math.ceil(total / (filters.limit || 20)),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async getExecutionById(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
|
||||
const execution = await this.executionService.findById(id, tenantId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: execution,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async cancelExecution(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
|
||||
const execution = await this.executionService.cancelExecution(id, tenantId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Execution cancelled',
|
||||
data: execution,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== SCHEDULES ====================
|
||||
|
||||
private async getSchedules(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
const { reportDefinitionId, isActive, search, page, limit } = req.query;
|
||||
|
||||
const filters: ReportScheduleFiltersDto = {
|
||||
reportDefinitionId: reportDefinitionId as string,
|
||||
isActive: isActive !== undefined ? isActive === 'true' : undefined,
|
||||
search: search as string,
|
||||
page: page ? parseInt(page as string, 10) : undefined,
|
||||
limit: limit ? parseInt(limit as string, 10) : undefined,
|
||||
};
|
||||
|
||||
const { data, total } = await this.reportsService.findAllSchedules(tenantId, filters);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data,
|
||||
pagination: {
|
||||
page: filters.page || 1,
|
||||
limit: filters.limit || 20,
|
||||
total,
|
||||
totalPages: Math.ceil(total / (filters.limit || 20)),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async getScheduleById(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
|
||||
const schedule = await this.reportsService.findScheduleById(id, tenantId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: schedule,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async createSchedule(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
const userId = req.headers['x-user-id'] as string;
|
||||
const dto: CreateReportScheduleDto = req.body;
|
||||
|
||||
const schedule = await this.reportsService.createSchedule(dto, tenantId, userId);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: schedule,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async updateSchedule(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
const dto: UpdateReportScheduleDto = req.body;
|
||||
|
||||
const schedule = await this.reportsService.updateSchedule(id, dto, tenantId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: schedule,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async toggleSchedule(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
const { isActive } = req.body;
|
||||
|
||||
const schedule = await this.reportsService.toggleSchedule(id, tenantId, isActive);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: isActive ? 'Schedule activated' : 'Schedule deactivated',
|
||||
data: schedule,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteSchedule(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
|
||||
await this.reportsService.deleteSchedule(id, tenantId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Schedule deleted',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async triggerSchedule(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
|
||||
const scheduleExecution = await this.schedulerService.triggerSchedule(id, tenantId);
|
||||
|
||||
res.status(202).json({
|
||||
success: true,
|
||||
message: 'Schedule triggered',
|
||||
data: scheduleExecution,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async getScheduleHistory(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { limit } = req.query;
|
||||
|
||||
const history = await this.schedulerService.getScheduleHistory(
|
||||
id,
|
||||
limit ? parseInt(limit as string, 10) : undefined
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: history,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== RECIPIENTS ====================
|
||||
|
||||
private async getRecipients(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const { scheduleId } = req.params;
|
||||
|
||||
const recipients = await this.reportsService.findRecipientsBySchedule(scheduleId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: recipients,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async addRecipient(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const { scheduleId } = req.params;
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
const dto: CreateReportRecipientDto = { ...req.body, scheduleId };
|
||||
|
||||
const recipient = await this.reportsService.addRecipient(dto, tenantId);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: recipient,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async removeRecipient(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
await this.reportsService.removeRecipient(id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Recipient removed',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== CUSTOM REPORTS ====================
|
||||
|
||||
private async getCustomReports(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
const userId = req.headers['x-user-id'] as string;
|
||||
const { baseDefinitionId, isFavorite, search, page, limit } = req.query;
|
||||
|
||||
const filters: CustomReportFiltersDto = {
|
||||
baseDefinitionId: baseDefinitionId as string,
|
||||
isFavorite: isFavorite !== undefined ? isFavorite === 'true' : undefined,
|
||||
search: search as string,
|
||||
page: page ? parseInt(page as string, 10) : undefined,
|
||||
limit: limit ? parseInt(limit as string, 10) : undefined,
|
||||
};
|
||||
|
||||
const { data, total } = await this.reportsService.findAllCustomReports(tenantId, userId, filters);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data,
|
||||
pagination: {
|
||||
page: filters.page || 1,
|
||||
limit: filters.limit || 20,
|
||||
total,
|
||||
totalPages: Math.ceil(total / (filters.limit || 20)),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async getCustomReportById(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
const userId = req.headers['x-user-id'] as string;
|
||||
|
||||
const customReport = await this.reportsService.findCustomReportById(id, tenantId, userId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: customReport,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async createCustomReport(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
const userId = req.headers['x-user-id'] as string;
|
||||
const dto: CreateCustomReportDto = req.body;
|
||||
|
||||
const customReport = await this.reportsService.createCustomReport(dto, tenantId, userId);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: customReport,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async updateCustomReport(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
const userId = req.headers['x-user-id'] as string;
|
||||
const dto: UpdateCustomReportDto = req.body;
|
||||
|
||||
const customReport = await this.reportsService.updateCustomReport(id, dto, tenantId, userId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: customReport,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async toggleFavorite(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
const userId = req.headers['x-user-id'] as string;
|
||||
|
||||
const customReport = await this.reportsService.toggleFavorite(id, tenantId, userId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: customReport,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteCustomReport(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
const userId = req.headers['x-user-id'] as string;
|
||||
|
||||
await this.reportsService.deleteCustomReport(id, tenantId, userId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Custom report deleted',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
102
src/modules/reports/dto/create-dashboard.dto.ts
Normal file
102
src/modules/reports/dto/create-dashboard.dto.ts
Normal file
@ -0,0 +1,102 @@
|
||||
import { WidgetType } from '../entities';
|
||||
|
||||
/**
|
||||
* DTO for creating a dashboard.
|
||||
*/
|
||||
export interface CreateDashboardDto {
|
||||
name: string;
|
||||
description?: string;
|
||||
slug?: string;
|
||||
icon?: string;
|
||||
layoutConfig?: Record<string, any>;
|
||||
isDefault?: boolean;
|
||||
isPublic?: boolean;
|
||||
isActive?: boolean;
|
||||
allowedRoles?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for updating a dashboard.
|
||||
*/
|
||||
export interface UpdateDashboardDto {
|
||||
name?: string;
|
||||
description?: string;
|
||||
slug?: string;
|
||||
icon?: string;
|
||||
layoutConfig?: Record<string, any>;
|
||||
isDefault?: boolean;
|
||||
isPublic?: boolean;
|
||||
isActive?: boolean;
|
||||
allowedRoles?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for creating a dashboard widget.
|
||||
*/
|
||||
export interface CreateDashboardWidgetDto {
|
||||
dashboardId: string;
|
||||
title: string;
|
||||
widgetType: WidgetType;
|
||||
positionX?: number;
|
||||
positionY?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
config?: Record<string, any>;
|
||||
refreshIntervalSeconds?: number;
|
||||
isActive?: boolean;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for updating a dashboard widget.
|
||||
*/
|
||||
export interface UpdateDashboardWidgetDto {
|
||||
title?: string;
|
||||
widgetType?: WidgetType;
|
||||
positionX?: number;
|
||||
positionY?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
config?: Record<string, any>;
|
||||
refreshIntervalSeconds?: number;
|
||||
isActive?: boolean;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for creating a widget query.
|
||||
*/
|
||||
export interface CreateWidgetQueryDto {
|
||||
widgetId: string;
|
||||
name: string;
|
||||
queryText?: string;
|
||||
queryFunction?: string;
|
||||
parameters?: Record<string, any>;
|
||||
resultMapping?: Record<string, any>;
|
||||
cacheTtlSeconds?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for updating a widget query.
|
||||
*/
|
||||
export interface UpdateWidgetQueryDto {
|
||||
name?: string;
|
||||
queryText?: string;
|
||||
queryFunction?: string;
|
||||
parameters?: Record<string, any>;
|
||||
resultMapping?: Record<string, any>;
|
||||
cacheTtlSeconds?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for bulk updating widget positions.
|
||||
*/
|
||||
export interface UpdateWidgetPositionsDto {
|
||||
widgets: Array<{
|
||||
id: string;
|
||||
positionX: number;
|
||||
positionY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}>;
|
||||
}
|
||||
62
src/modules/reports/dto/create-report.dto.ts
Normal file
62
src/modules/reports/dto/create-report.dto.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { ReportType } from '../entities';
|
||||
|
||||
/**
|
||||
* DTO for creating a report definition.
|
||||
*/
|
||||
export interface CreateReportDefinitionDto {
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
reportType?: ReportType;
|
||||
baseQuery?: string;
|
||||
queryFunction?: string;
|
||||
isSqlBased?: boolean;
|
||||
parametersSchema?: Record<string, any>[];
|
||||
defaultParameters?: Record<string, any>;
|
||||
columnsConfig?: Record<string, any>[];
|
||||
totalsConfig?: Record<string, any>[];
|
||||
requiredPermissions?: string[];
|
||||
isPublic?: boolean;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for creating a report schedule.
|
||||
*/
|
||||
export interface CreateReportScheduleDto {
|
||||
reportDefinitionId: string;
|
||||
name: string;
|
||||
cronExpression: string;
|
||||
timezone?: string;
|
||||
parameters?: Record<string, any>;
|
||||
deliveryMethod?: string;
|
||||
deliveryConfig?: Record<string, any>;
|
||||
exportFormat?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for creating a report recipient.
|
||||
*/
|
||||
export interface CreateReportRecipientDto {
|
||||
scheduleId: string;
|
||||
userId?: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for creating a custom report.
|
||||
*/
|
||||
export interface CreateCustomReportDto {
|
||||
baseDefinitionId?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
customColumns?: Record<string, any>[];
|
||||
customFilters?: Record<string, any>[];
|
||||
customGrouping?: Record<string, any>[];
|
||||
customSorting?: Record<string, any>[];
|
||||
isFavorite?: boolean;
|
||||
}
|
||||
26
src/modules/reports/dto/execute-report.dto.ts
Normal file
26
src/modules/reports/dto/execute-report.dto.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { ExportFormat } from '../entities';
|
||||
|
||||
/**
|
||||
* DTO for executing a report.
|
||||
*/
|
||||
export interface ExecuteReportDto {
|
||||
reportDefinitionId: string;
|
||||
parameters: Record<string, any>;
|
||||
exportFormat?: ExportFormat;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for cancelling a report execution.
|
||||
*/
|
||||
export interface CancelExecutionDto {
|
||||
executionId: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for exporting a report execution result.
|
||||
*/
|
||||
export interface ExportExecutionDto {
|
||||
executionId: string;
|
||||
format: ExportFormat;
|
||||
}
|
||||
52
src/modules/reports/dto/index.ts
Normal file
52
src/modules/reports/dto/index.ts
Normal file
@ -0,0 +1,52 @@
|
||||
// Create DTOs
|
||||
export type {
|
||||
CreateReportDefinitionDto,
|
||||
CreateReportScheduleDto,
|
||||
CreateReportRecipientDto,
|
||||
CreateCustomReportDto,
|
||||
} from './create-report.dto';
|
||||
|
||||
// Update DTOs
|
||||
export type {
|
||||
UpdateReportDefinitionDto,
|
||||
UpdateReportScheduleDto,
|
||||
UpdateReportRecipientDto,
|
||||
UpdateCustomReportDto,
|
||||
} from './update-report.dto';
|
||||
|
||||
// Execute DTOs
|
||||
export type {
|
||||
ExecuteReportDto,
|
||||
CancelExecutionDto,
|
||||
ExportExecutionDto,
|
||||
} from './execute-report.dto';
|
||||
|
||||
// Dashboard DTOs
|
||||
export type {
|
||||
CreateDashboardDto,
|
||||
UpdateDashboardDto,
|
||||
CreateDashboardWidgetDto,
|
||||
UpdateDashboardWidgetDto,
|
||||
CreateWidgetQueryDto,
|
||||
UpdateWidgetQueryDto,
|
||||
UpdateWidgetPositionsDto,
|
||||
} from './create-dashboard.dto';
|
||||
|
||||
// Filter DTOs
|
||||
export {
|
||||
ParamType,
|
||||
FilterOperator,
|
||||
} from './report-filters.dto';
|
||||
|
||||
export type {
|
||||
PaginationDto,
|
||||
ReportDefinitionFiltersDto,
|
||||
ReportExecutionFiltersDto,
|
||||
ReportScheduleFiltersDto,
|
||||
DashboardFiltersDto,
|
||||
DashboardWidgetFiltersDto,
|
||||
CustomReportFiltersDto,
|
||||
ReportParameterDefinition,
|
||||
ReportColumnDefinition,
|
||||
ReportFilterDefinition,
|
||||
} from './report-filters.dto';
|
||||
146
src/modules/reports/dto/report-filters.dto.ts
Normal file
146
src/modules/reports/dto/report-filters.dto.ts
Normal file
@ -0,0 +1,146 @@
|
||||
import { ReportType, ExecutionStatus, ExportFormat, WidgetType } from '../entities';
|
||||
|
||||
/**
|
||||
* Parameter type enum for report parameters
|
||||
*/
|
||||
export enum ParamType {
|
||||
STRING = 'string',
|
||||
NUMBER = 'number',
|
||||
DATE = 'date',
|
||||
DATERANGE = 'daterange',
|
||||
BOOLEAN = 'boolean',
|
||||
SELECT = 'select',
|
||||
MULTISELECT = 'multiselect',
|
||||
ENTITY = 'entity',
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter operator enum
|
||||
*/
|
||||
export enum FilterOperator {
|
||||
EQ = 'eq',
|
||||
NE = 'ne',
|
||||
GT = 'gt',
|
||||
GTE = 'gte',
|
||||
LT = 'lt',
|
||||
LTE = 'lte',
|
||||
LIKE = 'like',
|
||||
ILIKE = 'ilike',
|
||||
IN = 'in',
|
||||
NOT_IN = 'not_in',
|
||||
BETWEEN = 'between',
|
||||
IS_NULL = 'is_null',
|
||||
IS_NOT_NULL = 'is_not_null',
|
||||
}
|
||||
|
||||
/**
|
||||
* Base pagination DTO
|
||||
*/
|
||||
export interface PaginationDto {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for filtering report definitions.
|
||||
*/
|
||||
export interface ReportDefinitionFiltersDto extends PaginationDto {
|
||||
reportType?: ReportType;
|
||||
category?: string;
|
||||
isPublic?: boolean;
|
||||
isActive?: boolean;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for filtering report executions.
|
||||
*/
|
||||
export interface ReportExecutionFiltersDto extends PaginationDto {
|
||||
reportDefinitionId?: string;
|
||||
status?: ExecutionStatus;
|
||||
executedBy?: string;
|
||||
exportFormat?: ExportFormat;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for filtering report schedules.
|
||||
*/
|
||||
export interface ReportScheduleFiltersDto extends PaginationDto {
|
||||
reportDefinitionId?: string;
|
||||
isActive?: boolean;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for filtering dashboards.
|
||||
*/
|
||||
export interface DashboardFiltersDto extends PaginationDto {
|
||||
ownerId?: string;
|
||||
isDefault?: boolean;
|
||||
isPublic?: boolean;
|
||||
isActive?: boolean;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for filtering dashboard widgets.
|
||||
*/
|
||||
export interface DashboardWidgetFiltersDto extends PaginationDto {
|
||||
dashboardId?: string;
|
||||
widgetType?: WidgetType;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for filtering custom reports.
|
||||
*/
|
||||
export interface CustomReportFiltersDto extends PaginationDto {
|
||||
baseDefinitionId?: string;
|
||||
isFavorite?: boolean;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameter definition for report parameters schema
|
||||
*/
|
||||
export interface ReportParameterDefinition {
|
||||
name: string;
|
||||
label: string;
|
||||
type: ParamType;
|
||||
required: boolean;
|
||||
defaultValue?: any;
|
||||
options?: Array<{ value: any; label: string }>;
|
||||
entityType?: string;
|
||||
validation?: {
|
||||
min?: number;
|
||||
max?: number;
|
||||
pattern?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Column definition for report columns config
|
||||
*/
|
||||
export interface ReportColumnDefinition {
|
||||
field: string;
|
||||
header: string;
|
||||
type: 'string' | 'number' | 'date' | 'boolean' | 'currency' | 'percentage';
|
||||
width?: number;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
format?: string;
|
||||
sortable?: boolean;
|
||||
filterable?: boolean;
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter definition for custom reports
|
||||
*/
|
||||
export interface ReportFilterDefinition {
|
||||
field: string;
|
||||
operator: FilterOperator;
|
||||
value: any;
|
||||
valueEnd?: any; // For BETWEEN operator
|
||||
}
|
||||
59
src/modules/reports/dto/update-report.dto.ts
Normal file
59
src/modules/reports/dto/update-report.dto.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { ReportType, DeliveryMethod, ExportFormat } from '../entities';
|
||||
|
||||
/**
|
||||
* DTO for updating a report definition.
|
||||
*/
|
||||
export interface UpdateReportDefinitionDto {
|
||||
name?: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
reportType?: ReportType;
|
||||
baseQuery?: string;
|
||||
queryFunction?: string;
|
||||
isSqlBased?: boolean;
|
||||
parametersSchema?: Record<string, any>[];
|
||||
defaultParameters?: Record<string, any>;
|
||||
columnsConfig?: Record<string, any>[];
|
||||
totalsConfig?: Record<string, any>[];
|
||||
requiredPermissions?: string[];
|
||||
isPublic?: boolean;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for updating a report schedule.
|
||||
*/
|
||||
export interface UpdateReportScheduleDto {
|
||||
name?: string;
|
||||
cronExpression?: string;
|
||||
timezone?: string;
|
||||
parameters?: Record<string, any>;
|
||||
deliveryMethod?: DeliveryMethod;
|
||||
deliveryConfig?: Record<string, any>;
|
||||
exportFormat?: ExportFormat;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for updating a report recipient.
|
||||
*/
|
||||
export interface UpdateReportRecipientDto {
|
||||
userId?: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for updating a custom report.
|
||||
*/
|
||||
export interface UpdateCustomReportDto {
|
||||
baseDefinitionId?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
customColumns?: Record<string, any>[];
|
||||
customFilters?: Record<string, any>[];
|
||||
customGrouping?: Record<string, any>[];
|
||||
customSorting?: Record<string, any>[];
|
||||
isFavorite?: boolean;
|
||||
}
|
||||
67
src/modules/reports/entities/custom-report.entity.ts
Normal file
67
src/modules/reports/entities/custom-report.entity.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { ReportDefinition } from './report-definition.entity';
|
||||
|
||||
/**
|
||||
* Custom Report Entity (schema: reports.custom_reports)
|
||||
*
|
||||
* User-personalized reports based on existing definitions.
|
||||
* Stores custom columns, filters, grouping, and sorting preferences.
|
||||
*/
|
||||
@Entity({ name: 'custom_reports', schema: 'reports' })
|
||||
export class CustomReport {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'owner_id', type: 'uuid' })
|
||||
ownerId: string;
|
||||
|
||||
@Column({ name: 'base_definition_id', type: 'uuid', nullable: true })
|
||||
baseDefinitionId: string | null;
|
||||
|
||||
@Column({ name: 'name', type: 'varchar', length: 255 })
|
||||
name: string;
|
||||
|
||||
@Column({ name: 'description', type: 'text', nullable: true })
|
||||
description: string | null;
|
||||
|
||||
@Column({ name: 'custom_columns', type: 'jsonb', default: '[]' })
|
||||
customColumns: Record<string, any>[];
|
||||
|
||||
@Column({ name: 'custom_filters', type: 'jsonb', default: '[]' })
|
||||
customFilters: Record<string, any>[];
|
||||
|
||||
@Column({ name: 'custom_grouping', type: 'jsonb', default: '[]' })
|
||||
customGrouping: Record<string, any>[];
|
||||
|
||||
@Column({ name: 'custom_sorting', type: 'jsonb', default: '[]' })
|
||||
customSorting: Record<string, any>[];
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'is_favorite', type: 'boolean', default: false })
|
||||
isFavorite: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => ReportDefinition, (definition) => definition.customReports, { onDelete: 'SET NULL' })
|
||||
@JoinColumn({ name: 'base_definition_id' })
|
||||
baseDefinition: ReportDefinition | null;
|
||||
}
|
||||
94
src/modules/reports/entities/dashboard-widget.entity.ts
Normal file
94
src/modules/reports/entities/dashboard-widget.entity.ts
Normal file
@ -0,0 +1,94 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
OneToMany,
|
||||
} from 'typeorm';
|
||||
import { Dashboard } from './dashboard.entity';
|
||||
import { WidgetQuery } from './widget-query.entity';
|
||||
|
||||
/**
|
||||
* Widget type enum
|
||||
*/
|
||||
export enum WidgetType {
|
||||
KPI = 'kpi',
|
||||
BAR_CHART = 'bar_chart',
|
||||
LINE_CHART = 'line_chart',
|
||||
PIE_CHART = 'pie_chart',
|
||||
DONUT_CHART = 'donut_chart',
|
||||
GAUGE = 'gauge',
|
||||
TABLE = 'table',
|
||||
MAP = 'map',
|
||||
TEXT = 'text',
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard Widget Entity (schema: reports.dashboard_widgets)
|
||||
*
|
||||
* Individual visualization components within a dashboard.
|
||||
* Configures position, size, type, and data source.
|
||||
*/
|
||||
@Entity({ name: 'dashboard_widgets', schema: 'reports' })
|
||||
export class DashboardWidget {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'dashboard_id', type: 'uuid' })
|
||||
dashboardId: string;
|
||||
|
||||
@Column({ name: 'title', type: 'varchar', length: 255 })
|
||||
title: string;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
name: 'widget_type',
|
||||
type: 'enum',
|
||||
enum: WidgetType,
|
||||
enumName: 'widget_type',
|
||||
})
|
||||
widgetType: WidgetType;
|
||||
|
||||
@Column({ name: 'position_x', type: 'int', default: 0 })
|
||||
positionX: number;
|
||||
|
||||
@Column({ name: 'position_y', type: 'int', default: 0 })
|
||||
positionY: number;
|
||||
|
||||
@Column({ name: 'width', type: 'int', default: 3 })
|
||||
width: number;
|
||||
|
||||
@Column({ name: 'height', type: 'int', default: 2 })
|
||||
height: number;
|
||||
|
||||
@Column({ name: 'config', type: 'jsonb', default: '{}' })
|
||||
config: Record<string, any>;
|
||||
|
||||
@Column({ name: 'refresh_interval_seconds', type: 'int', nullable: true, default: 300 })
|
||||
refreshIntervalSeconds: number | null;
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive: boolean;
|
||||
|
||||
@Column({ name: 'sort_order', type: 'int', default: 0 })
|
||||
sortOrder: number;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => Dashboard, (dashboard) => dashboard.widgets, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'dashboard_id' })
|
||||
dashboard: Dashboard;
|
||||
|
||||
@OneToMany(() => WidgetQuery, (query) => query.widget)
|
||||
queries: WidgetQuery[];
|
||||
}
|
||||
68
src/modules/reports/entities/dashboard.entity.ts
Normal file
68
src/modules/reports/entities/dashboard.entity.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
OneToMany,
|
||||
} from 'typeorm';
|
||||
import { DashboardWidget } from './dashboard-widget.entity';
|
||||
|
||||
/**
|
||||
* Dashboard Entity (schema: reports.dashboards)
|
||||
*
|
||||
* Custom dashboards containing widgets for data visualization.
|
||||
* Supports multi-tenant isolation and role-based access.
|
||||
*/
|
||||
@Entity({ name: 'dashboards', schema: 'reports' })
|
||||
export class Dashboard {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ name: 'name', type: 'varchar', length: 255 })
|
||||
name: string;
|
||||
|
||||
@Column({ name: 'description', type: 'text', nullable: true })
|
||||
description: string | null;
|
||||
|
||||
@Column({ name: 'slug', type: 'varchar', length: 100, nullable: true })
|
||||
slug: string | null;
|
||||
|
||||
@Column({ name: 'icon', type: 'varchar', length: 50, nullable: true })
|
||||
icon: string | null;
|
||||
|
||||
@Column({ name: 'layout_config', type: 'jsonb', default: '{"columns": 12, "rowHeight": 80}' })
|
||||
layoutConfig: Record<string, any>;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'is_default', type: 'boolean', default: false })
|
||||
isDefault: boolean;
|
||||
|
||||
@Column({ name: 'is_public', type: 'boolean', default: false })
|
||||
isPublic: boolean;
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive: boolean;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'owner_id', type: 'uuid' })
|
||||
ownerId: string;
|
||||
|
||||
@Column({ name: 'allowed_roles', type: 'text', array: true, default: '{}' })
|
||||
allowedRoles: string[];
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
// Relations
|
||||
@OneToMany(() => DashboardWidget, (widget) => widget.dashboard)
|
||||
widgets: DashboardWidget[];
|
||||
}
|
||||
69
src/modules/reports/entities/data-model-entity.entity.ts
Normal file
69
src/modules/reports/entities/data-model-entity.entity.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
OneToMany,
|
||||
} from 'typeorm';
|
||||
import { DataModelField } from './data-model-field.entity';
|
||||
import { DataModelRelationship } from './data-model-relationship.entity';
|
||||
|
||||
/**
|
||||
* Data Model Entity (schema: reports.data_model_entities)
|
||||
*
|
||||
* Represents database tables/entities available for report building.
|
||||
* Used by the report builder UI to construct dynamic queries.
|
||||
*/
|
||||
@Entity({ name: 'data_model_entities', schema: 'reports' })
|
||||
@Index(['name'], { unique: true })
|
||||
export class DataModelEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'name', type: 'varchar', length: 100 })
|
||||
name: string;
|
||||
|
||||
@Column({ name: 'display_name', type: 'varchar', length: 255 })
|
||||
displayName: string;
|
||||
|
||||
@Column({ name: 'description', type: 'text', nullable: true })
|
||||
description: string | null;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'schema_name', type: 'varchar', length: 100 })
|
||||
schemaName: string;
|
||||
|
||||
@Column({ name: 'table_name', type: 'varchar', length: 100 })
|
||||
tableName: string;
|
||||
|
||||
@Column({ name: 'primary_key_column', type: 'varchar', length: 100, default: 'id' })
|
||||
primaryKeyColumn: string;
|
||||
|
||||
@Column({ name: 'tenant_column', type: 'varchar', length: 100, nullable: true, default: 'tenant_id' })
|
||||
tenantColumn: string | null;
|
||||
|
||||
@Column({ name: 'is_multi_tenant', type: 'boolean', default: true })
|
||||
isMultiTenant: boolean;
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
// Relations
|
||||
@OneToMany(() => DataModelField, (field) => field.entity)
|
||||
fields: DataModelField[];
|
||||
|
||||
@OneToMany(() => DataModelRelationship, (rel) => rel.sourceEntity)
|
||||
sourceRelationships: DataModelRelationship[];
|
||||
|
||||
@OneToMany(() => DataModelRelationship, (rel) => rel.targetEntity)
|
||||
targetRelationships: DataModelRelationship[];
|
||||
}
|
||||
77
src/modules/reports/entities/data-model-field.entity.ts
Normal file
77
src/modules/reports/entities/data-model-field.entity.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Index,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { DataModelEntity } from './data-model-entity.entity';
|
||||
|
||||
/**
|
||||
* Data Model Field Entity (schema: reports.data_model_fields)
|
||||
*
|
||||
* Represents columns/fields within a data model entity.
|
||||
* Includes metadata for filtering, sorting, grouping, and formatting.
|
||||
*/
|
||||
@Entity({ name: 'data_model_fields', schema: 'reports' })
|
||||
@Index(['entityId', 'name'], { unique: true })
|
||||
export class DataModelField {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'entity_id', type: 'uuid' })
|
||||
entityId: string;
|
||||
|
||||
@Column({ name: 'name', type: 'varchar', length: 100 })
|
||||
name: string;
|
||||
|
||||
@Column({ name: 'display_name', type: 'varchar', length: 255 })
|
||||
displayName: string;
|
||||
|
||||
@Column({ name: 'description', type: 'text', nullable: true })
|
||||
description: string | null;
|
||||
|
||||
@Column({ name: 'data_type', type: 'varchar', length: 50 })
|
||||
dataType: string;
|
||||
|
||||
@Column({ name: 'is_nullable', type: 'boolean', default: true })
|
||||
isNullable: boolean;
|
||||
|
||||
@Column({ name: 'is_filterable', type: 'boolean', default: true })
|
||||
isFilterable: boolean;
|
||||
|
||||
@Column({ name: 'is_sortable', type: 'boolean', default: true })
|
||||
isSortable: boolean;
|
||||
|
||||
@Column({ name: 'is_groupable', type: 'boolean', default: false })
|
||||
isGroupable: boolean;
|
||||
|
||||
@Column({ name: 'is_aggregatable', type: 'boolean', default: false })
|
||||
isAggregatable: boolean;
|
||||
|
||||
@Column({ name: 'aggregation_functions', type: 'text', array: true, default: '{}' })
|
||||
aggregationFunctions: string[];
|
||||
|
||||
@Column({ name: 'format_pattern', type: 'varchar', length: 100, nullable: true })
|
||||
formatPattern: string | null;
|
||||
|
||||
@Column({ name: 'display_format', type: 'varchar', length: 50, nullable: true })
|
||||
displayFormat: string | null;
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive: boolean;
|
||||
|
||||
@Column({ name: 'sort_order', type: 'int', default: 0 })
|
||||
sortOrder: number;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => DataModelEntity, (entity) => entity.fields, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'entity_id' })
|
||||
entity: DataModelEntity;
|
||||
}
|
||||
@ -0,0 +1,75 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Index,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { DataModelEntity } from './data-model-entity.entity';
|
||||
|
||||
/**
|
||||
* Relationship type enum
|
||||
*/
|
||||
export enum RelationshipType {
|
||||
ONE_TO_ONE = 'one_to_one',
|
||||
ONE_TO_MANY = 'one_to_many',
|
||||
MANY_TO_ONE = 'many_to_one',
|
||||
MANY_TO_MANY = 'many_to_many',
|
||||
}
|
||||
|
||||
/**
|
||||
* Data Model Relationship Entity (schema: reports.data_model_relationships)
|
||||
*
|
||||
* Defines relationships between data model entities for join operations
|
||||
* in the report builder.
|
||||
*/
|
||||
@Entity({ name: 'data_model_relationships', schema: 'reports' })
|
||||
@Index(['sourceEntityId', 'targetEntityId', 'name'], { unique: true })
|
||||
export class DataModelRelationship {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'source_entity_id', type: 'uuid' })
|
||||
sourceEntityId: string;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'target_entity_id', type: 'uuid' })
|
||||
targetEntityId: string;
|
||||
|
||||
@Column({ name: 'name', type: 'varchar', length: 100 })
|
||||
name: string;
|
||||
|
||||
@Column({
|
||||
name: 'relationship_type',
|
||||
type: 'varchar',
|
||||
length: 20,
|
||||
})
|
||||
relationshipType: RelationshipType;
|
||||
|
||||
@Column({ name: 'source_column', type: 'varchar', length: 100 })
|
||||
sourceColumn: string;
|
||||
|
||||
@Column({ name: 'target_column', type: 'varchar', length: 100 })
|
||||
targetColumn: string;
|
||||
|
||||
@Column({ name: 'join_condition', type: 'text', nullable: true })
|
||||
joinCondition: string | null;
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => DataModelEntity, (entity) => entity.sourceRelationships, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'source_entity_id' })
|
||||
sourceEntity: DataModelEntity;
|
||||
|
||||
@ManyToOne(() => DataModelEntity, (entity) => entity.targetRelationships, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'target_entity_id' })
|
||||
targetEntity: DataModelEntity;
|
||||
}
|
||||
35
src/modules/reports/entities/index.ts
Normal file
35
src/modules/reports/entities/index.ts
Normal file
@ -0,0 +1,35 @@
|
||||
// Report Definition
|
||||
export { ReportDefinition, ReportType } from './report-definition.entity';
|
||||
|
||||
// Report Execution
|
||||
export { ReportExecution, ExecutionStatus, ExportFormat } from './report-execution.entity';
|
||||
|
||||
// Report Schedule
|
||||
export { ReportSchedule, DeliveryMethod } from './report-schedule.entity';
|
||||
|
||||
// Report Recipient
|
||||
export { ReportRecipient } from './report-recipient.entity';
|
||||
|
||||
// Schedule Execution
|
||||
export { ScheduleExecution } from './schedule-execution.entity';
|
||||
|
||||
// Dashboard
|
||||
export { Dashboard } from './dashboard.entity';
|
||||
|
||||
// Dashboard Widget
|
||||
export { DashboardWidget, WidgetType } from './dashboard-widget.entity';
|
||||
|
||||
// Widget Query
|
||||
export { WidgetQuery } from './widget-query.entity';
|
||||
|
||||
// Custom Report
|
||||
export { CustomReport } from './custom-report.entity';
|
||||
|
||||
// Data Model Entity
|
||||
export { DataModelEntity } from './data-model-entity.entity';
|
||||
|
||||
// Data Model Field
|
||||
export { DataModelField } from './data-model-field.entity';
|
||||
|
||||
// Data Model Relationship
|
||||
export { DataModelRelationship, RelationshipType } from './data-model-relationship.entity';
|
||||
116
src/modules/reports/entities/report-definition.entity.ts
Normal file
116
src/modules/reports/entities/report-definition.entity.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
OneToMany,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { ReportExecution } from './report-execution.entity';
|
||||
import { ReportSchedule } from './report-schedule.entity';
|
||||
import { CustomReport } from './custom-report.entity';
|
||||
|
||||
/**
|
||||
* Report type enum
|
||||
*/
|
||||
export enum ReportType {
|
||||
FINANCIAL = 'financial',
|
||||
ACCOUNTING = 'accounting',
|
||||
TAX = 'tax',
|
||||
MANAGEMENT = 'management',
|
||||
OPERATIONAL = 'operational',
|
||||
CUSTOM = 'custom',
|
||||
}
|
||||
|
||||
/**
|
||||
* Report Definition Entity (schema: reports.report_definitions)
|
||||
*
|
||||
* Defines the structure and configuration of reports.
|
||||
* Contains the base query, parameters schema, and column configuration.
|
||||
*/
|
||||
@Entity({ name: 'report_definitions', schema: 'reports' })
|
||||
@Index(['tenantId', 'code'], { unique: true })
|
||||
export class ReportDefinition {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ name: 'code', type: 'varchar', length: 50 })
|
||||
code: string;
|
||||
|
||||
@Column({ name: 'name', type: 'varchar', length: 255 })
|
||||
name: string;
|
||||
|
||||
@Column({ name: 'description', type: 'text', nullable: true })
|
||||
description: string | null;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'category', type: 'varchar', length: 100, nullable: true })
|
||||
category: string | null;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
name: 'report_type',
|
||||
type: 'enum',
|
||||
enum: ReportType,
|
||||
enumName: 'report_type',
|
||||
default: ReportType.CUSTOM,
|
||||
})
|
||||
reportType: ReportType;
|
||||
|
||||
@Column({ name: 'base_query', type: 'text', nullable: true })
|
||||
baseQuery: string | null;
|
||||
|
||||
@Column({ name: 'query_function', type: 'varchar', length: 255, nullable: true })
|
||||
queryFunction: string | null;
|
||||
|
||||
@Column({ name: 'is_sql_based', type: 'boolean', default: true })
|
||||
isSqlBased: boolean;
|
||||
|
||||
@Column({ name: 'parameters_schema', type: 'jsonb', default: '[]' })
|
||||
parametersSchema: Record<string, any>[];
|
||||
|
||||
@Column({ name: 'default_parameters', type: 'jsonb', default: '{}' })
|
||||
defaultParameters: Record<string, any>;
|
||||
|
||||
@Column({ name: 'columns_config', type: 'jsonb', default: '[]' })
|
||||
columnsConfig: Record<string, any>[];
|
||||
|
||||
@Column({ name: 'totals_config', type: 'jsonb', default: '[]' })
|
||||
totalsConfig: Record<string, any>[];
|
||||
|
||||
@Column({ name: 'required_permissions', type: 'text', array: true, default: '{}' })
|
||||
requiredPermissions: string[];
|
||||
|
||||
@Column({ name: 'is_public', type: 'boolean', default: false })
|
||||
isPublic: boolean;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||
createdBy: string | null;
|
||||
|
||||
// Relations
|
||||
@OneToMany(() => ReportExecution, (execution) => execution.reportDefinition)
|
||||
executions: ReportExecution[];
|
||||
|
||||
@OneToMany(() => ReportSchedule, (schedule) => schedule.reportDefinition)
|
||||
schedules: ReportSchedule[];
|
||||
|
||||
@OneToMany(() => CustomReport, (customReport) => customReport.baseDefinition)
|
||||
customReports: CustomReport[];
|
||||
}
|
||||
122
src/modules/reports/entities/report-execution.entity.ts
Normal file
122
src/modules/reports/entities/report-execution.entity.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Index,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
OneToMany,
|
||||
} from 'typeorm';
|
||||
import { ReportDefinition } from './report-definition.entity';
|
||||
import { ScheduleExecution } from './schedule-execution.entity';
|
||||
|
||||
/**
|
||||
* Execution status enum
|
||||
*/
|
||||
export enum ExecutionStatus {
|
||||
PENDING = 'pending',
|
||||
RUNNING = 'running',
|
||||
COMPLETED = 'completed',
|
||||
FAILED = 'failed',
|
||||
CANCELLED = 'cancelled',
|
||||
}
|
||||
|
||||
/**
|
||||
* Export format enum
|
||||
*/
|
||||
export enum ExportFormat {
|
||||
PDF = 'pdf',
|
||||
EXCEL = 'excel',
|
||||
CSV = 'csv',
|
||||
JSON = 'json',
|
||||
HTML = 'html',
|
||||
}
|
||||
|
||||
/**
|
||||
* Report Execution Entity (schema: reports.report_executions)
|
||||
*
|
||||
* Stores the history of report executions including parameters,
|
||||
* results, and execution statistics.
|
||||
*/
|
||||
@Entity({ name: 'report_executions', schema: 'reports' })
|
||||
export class ReportExecution {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'report_definition_id', type: 'uuid' })
|
||||
reportDefinitionId: string;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'executed_by', type: 'uuid' })
|
||||
executedBy: string;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
name: 'status',
|
||||
type: 'enum',
|
||||
enum: ExecutionStatus,
|
||||
enumName: 'execution_status',
|
||||
default: ExecutionStatus.PENDING,
|
||||
})
|
||||
status: ExecutionStatus;
|
||||
|
||||
@Column({ name: 'parameters', type: 'jsonb', default: '{}' })
|
||||
parameters: Record<string, any>;
|
||||
|
||||
@Column({ name: 'result_data', type: 'jsonb', nullable: true })
|
||||
resultData: any;
|
||||
|
||||
@Column({ name: 'result_summary', type: 'jsonb', nullable: true })
|
||||
resultSummary: Record<string, any> | null;
|
||||
|
||||
@Column({ name: 'row_count', type: 'int', nullable: true })
|
||||
rowCount: number | null;
|
||||
|
||||
@Column({
|
||||
name: 'export_format',
|
||||
type: 'enum',
|
||||
enum: ExportFormat,
|
||||
enumName: 'export_format',
|
||||
nullable: true,
|
||||
})
|
||||
exportFormat: ExportFormat | null;
|
||||
|
||||
@Column({ name: 'file_path', type: 'text', nullable: true })
|
||||
filePath: string | null;
|
||||
|
||||
@Column({ name: 'file_size_bytes', type: 'bigint', nullable: true })
|
||||
fileSizeBytes: number | null;
|
||||
|
||||
@Column({ name: 'started_at', type: 'timestamptz', nullable: true })
|
||||
startedAt: Date | null;
|
||||
|
||||
@Column({ name: 'completed_at', type: 'timestamptz', nullable: true })
|
||||
completedAt: Date | null;
|
||||
|
||||
@Column({ name: 'execution_time_ms', type: 'int', nullable: true })
|
||||
executionTimeMs: number | null;
|
||||
|
||||
@Column({ name: 'error_message', type: 'text', nullable: true })
|
||||
errorMessage: string | null;
|
||||
|
||||
@Column({ name: 'error_details', type: 'jsonb', nullable: true })
|
||||
errorDetails: Record<string, any> | null;
|
||||
|
||||
@Index()
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => ReportDefinition, (definition) => definition.executions, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'report_definition_id' })
|
||||
reportDefinition: ReportDefinition;
|
||||
|
||||
@OneToMany(() => ScheduleExecution, (scheduleExec) => scheduleExec.execution)
|
||||
scheduleExecutions: ScheduleExecution[];
|
||||
}
|
||||
47
src/modules/reports/entities/report-recipient.entity.ts
Normal file
47
src/modules/reports/entities/report-recipient.entity.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Index,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { ReportSchedule } from './report-schedule.entity';
|
||||
|
||||
/**
|
||||
* Report Recipient Entity (schema: reports.report_recipients)
|
||||
*
|
||||
* Stores recipients for scheduled reports. Can reference internal users
|
||||
* or external email addresses.
|
||||
*/
|
||||
@Entity({ name: 'report_recipients', schema: 'reports' })
|
||||
export class ReportRecipient {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'schedule_id', type: 'uuid' })
|
||||
scheduleId: string;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'user_id', type: 'uuid', nullable: true })
|
||||
userId: string | null;
|
||||
|
||||
@Column({ name: 'email', type: 'varchar', length: 255, nullable: true })
|
||||
email: string | null;
|
||||
|
||||
@Column({ name: 'name', type: 'varchar', length: 255, nullable: true })
|
||||
name: string | null;
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => ReportSchedule, (schedule) => schedule.recipients, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'schedule_id' })
|
||||
schedule: ReportSchedule;
|
||||
}
|
||||
121
src/modules/reports/entities/report-schedule.entity.ts
Normal file
121
src/modules/reports/entities/report-schedule.entity.ts
Normal file
@ -0,0 +1,121 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
OneToMany,
|
||||
} from 'typeorm';
|
||||
import { ReportDefinition } from './report-definition.entity';
|
||||
import { ReportRecipient } from './report-recipient.entity';
|
||||
import { ScheduleExecution } from './schedule-execution.entity';
|
||||
import { ExecutionStatus, ExportFormat } from './report-execution.entity';
|
||||
|
||||
/**
|
||||
* Delivery method enum
|
||||
*/
|
||||
export enum DeliveryMethod {
|
||||
NONE = 'none',
|
||||
EMAIL = 'email',
|
||||
STORAGE = 'storage',
|
||||
WEBHOOK = 'webhook',
|
||||
}
|
||||
|
||||
/**
|
||||
* Report Schedule Entity (schema: reports.report_schedules)
|
||||
*
|
||||
* Configures scheduled report execution with cron expressions,
|
||||
* delivery methods, and default parameters.
|
||||
*/
|
||||
@Entity({ name: 'report_schedules', schema: 'reports' })
|
||||
export class ReportSchedule {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'report_definition_id', type: 'uuid' })
|
||||
reportDefinitionId: string;
|
||||
|
||||
@Column({ name: 'name', type: 'varchar', length: 255 })
|
||||
name: string;
|
||||
|
||||
@Column({ name: 'cron_expression', type: 'varchar', length: 100 })
|
||||
cronExpression: string;
|
||||
|
||||
@Column({ name: 'timezone', type: 'varchar', length: 100, default: 'America/Mexico_City' })
|
||||
timezone: string;
|
||||
|
||||
@Column({ name: 'parameters', type: 'jsonb', default: '{}' })
|
||||
parameters: Record<string, any>;
|
||||
|
||||
@Column({
|
||||
name: 'delivery_method',
|
||||
type: 'enum',
|
||||
enum: DeliveryMethod,
|
||||
enumName: 'delivery_method',
|
||||
default: DeliveryMethod.EMAIL,
|
||||
})
|
||||
deliveryMethod: DeliveryMethod;
|
||||
|
||||
@Column({ name: 'delivery_config', type: 'jsonb', default: '{}' })
|
||||
deliveryConfig: Record<string, any>;
|
||||
|
||||
@Column({
|
||||
name: 'export_format',
|
||||
type: 'enum',
|
||||
enum: ExportFormat,
|
||||
enumName: 'export_format',
|
||||
default: ExportFormat.PDF,
|
||||
})
|
||||
exportFormat: ExportFormat;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive: boolean;
|
||||
|
||||
@Column({ name: 'last_run_at', type: 'timestamptz', nullable: true })
|
||||
lastRunAt: Date | null;
|
||||
|
||||
@Column({
|
||||
name: 'last_run_status',
|
||||
type: 'enum',
|
||||
enum: ExecutionStatus,
|
||||
enumName: 'execution_status',
|
||||
nullable: true,
|
||||
})
|
||||
lastRunStatus: ExecutionStatus | null;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'next_run_at', type: 'timestamptz', nullable: true })
|
||||
nextRunAt: Date | null;
|
||||
|
||||
@Column({ name: 'run_count', type: 'int', default: 0 })
|
||||
runCount: number;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||
createdBy: string | null;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => ReportDefinition, (definition) => definition.schedules, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'report_definition_id' })
|
||||
reportDefinition: ReportDefinition;
|
||||
|
||||
@OneToMany(() => ReportRecipient, (recipient) => recipient.schedule)
|
||||
recipients: ReportRecipient[];
|
||||
|
||||
@OneToMany(() => ScheduleExecution, (scheduleExec) => scheduleExec.schedule)
|
||||
scheduleExecutions: ScheduleExecution[];
|
||||
}
|
||||
59
src/modules/reports/entities/schedule-execution.entity.ts
Normal file
59
src/modules/reports/entities/schedule-execution.entity.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
Index,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { ReportSchedule } from './report-schedule.entity';
|
||||
import { ReportExecution, ExecutionStatus } from './report-execution.entity';
|
||||
|
||||
/**
|
||||
* Schedule Execution Entity (schema: reports.schedule_executions)
|
||||
*
|
||||
* Links scheduled reports to their actual executions,
|
||||
* tracking delivery status and recipient notifications.
|
||||
*/
|
||||
@Entity({ name: 'schedule_executions', schema: 'reports' })
|
||||
export class ScheduleExecution {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'schedule_id', type: 'uuid' })
|
||||
scheduleId: string;
|
||||
|
||||
@Column({ name: 'execution_id', type: 'uuid', nullable: true })
|
||||
executionId: string | null;
|
||||
|
||||
@Column({
|
||||
name: 'status',
|
||||
type: 'enum',
|
||||
enum: ExecutionStatus,
|
||||
enumName: 'execution_status',
|
||||
})
|
||||
status: ExecutionStatus;
|
||||
|
||||
@Column({ name: 'recipients_notified', type: 'int', default: 0 })
|
||||
recipientsNotified: number;
|
||||
|
||||
@Column({ name: 'delivery_status', type: 'jsonb', default: '{}' })
|
||||
deliveryStatus: Record<string, any>;
|
||||
|
||||
@Column({ name: 'error_message', type: 'text', nullable: true })
|
||||
errorMessage: string | null;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'executed_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
|
||||
executedAt: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => ReportSchedule, (schedule) => schedule.scheduleExecutions, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'schedule_id' })
|
||||
schedule: ReportSchedule;
|
||||
|
||||
@ManyToOne(() => ReportExecution, (execution) => execution.scheduleExecutions, { onDelete: 'SET NULL' })
|
||||
@JoinColumn({ name: 'execution_id' })
|
||||
execution: ReportExecution | null;
|
||||
}
|
||||
59
src/modules/reports/entities/widget-query.entity.ts
Normal file
59
src/modules/reports/entities/widget-query.entity.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { DashboardWidget } from './dashboard-widget.entity';
|
||||
|
||||
/**
|
||||
* Widget Query Entity (schema: reports.widget_queries)
|
||||
*
|
||||
* Data source queries for dashboard widgets.
|
||||
* Supports both raw SQL and function-based queries with caching.
|
||||
*/
|
||||
@Entity({ name: 'widget_queries', schema: 'reports' })
|
||||
export class WidgetQuery {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'widget_id', type: 'uuid' })
|
||||
widgetId: string;
|
||||
|
||||
@Column({ name: 'name', type: 'varchar', length: 100 })
|
||||
name: string;
|
||||
|
||||
@Column({ name: 'query_text', type: 'text', nullable: true })
|
||||
queryText: string | null;
|
||||
|
||||
@Column({ name: 'query_function', type: 'varchar', length: 255, nullable: true })
|
||||
queryFunction: string | null;
|
||||
|
||||
@Column({ name: 'parameters', type: 'jsonb', default: '{}' })
|
||||
parameters: Record<string, any>;
|
||||
|
||||
@Column({ name: 'result_mapping', type: 'jsonb', default: '{}' })
|
||||
resultMapping: Record<string, any>;
|
||||
|
||||
@Column({ name: 'cache_ttl_seconds', type: 'int', nullable: true, default: 300 })
|
||||
cacheTtlSeconds: number | null;
|
||||
|
||||
@Column({ name: 'last_cached_at', type: 'timestamptz', nullable: true })
|
||||
lastCachedAt: Date | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => DashboardWidget, (widget) => widget.queries, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'widget_id' })
|
||||
widget: DashboardWidget;
|
||||
}
|
||||
@ -1,3 +1,15 @@
|
||||
// Module exports
|
||||
export { ReportsModule, ReportsModuleOptions } from './reports.module';
|
||||
export { ReportsService } from './services';
|
||||
export { ReportsController } from './controllers';
|
||||
export { createReportsRoutes } from './reports.routes';
|
||||
|
||||
// Entity exports
|
||||
export * from './entities';
|
||||
|
||||
// Service exports
|
||||
export * from './services';
|
||||
|
||||
// Controller exports
|
||||
export * from './controllers';
|
||||
|
||||
// DTO exports
|
||||
export * from './dto';
|
||||
|
||||
@ -1,16 +1,68 @@
|
||||
import { Router } from 'express';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { ReportsService } from './services';
|
||||
import { ReportsController } from './controllers';
|
||||
import {
|
||||
ReportsService,
|
||||
ReportExecutionService,
|
||||
ReportSchedulerService,
|
||||
DashboardsService,
|
||||
LegacyReportsService,
|
||||
} from './services';
|
||||
import { ReportsController, DashboardsController, LegacyReportsController } from './controllers';
|
||||
import {
|
||||
ReportDefinition,
|
||||
ReportExecution,
|
||||
ReportSchedule,
|
||||
ReportRecipient,
|
||||
ScheduleExecution,
|
||||
Dashboard,
|
||||
DashboardWidget,
|
||||
WidgetQuery,
|
||||
CustomReport,
|
||||
DataModelEntity,
|
||||
DataModelField,
|
||||
DataModelRelationship,
|
||||
} from './entities';
|
||||
|
||||
/**
|
||||
* Options for configuring the Reports module
|
||||
*/
|
||||
export interface ReportsModuleOptions {
|
||||
dataSource: DataSource;
|
||||
basePath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ReportsModule
|
||||
*
|
||||
* Complete reports and dashboards management module.
|
||||
*
|
||||
* Features:
|
||||
* - Report definitions (templates)
|
||||
* - Report executions (history and results)
|
||||
* - Scheduled reports with delivery
|
||||
* - Custom user reports
|
||||
* - Dashboards with widgets
|
||||
* - Legacy analytics reports
|
||||
*
|
||||
* Routes:
|
||||
* - /api/v1/reports/definitions/* - Report definitions
|
||||
* - /api/v1/reports/execute - Execute reports
|
||||
* - /api/v1/reports/executions/* - Execution history
|
||||
* - /api/v1/reports/schedules/* - Scheduled reports
|
||||
* - /api/v1/reports/custom/* - Custom reports
|
||||
* - /api/v1/dashboards/* - Dashboards and widgets
|
||||
* - /api/v1/reports/legacy/* - Legacy analytics
|
||||
*/
|
||||
export class ReportsModule {
|
||||
public router: Router;
|
||||
|
||||
// Services
|
||||
public reportsService: ReportsService;
|
||||
public executionService: ReportExecutionService;
|
||||
public schedulerService: ReportSchedulerService;
|
||||
public dashboardsService: DashboardsService;
|
||||
public legacyService: LegacyReportsService;
|
||||
|
||||
private dataSource: DataSource;
|
||||
private basePath: string;
|
||||
|
||||
@ -22,17 +74,88 @@ export class ReportsModule {
|
||||
this.initializeRoutes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize all services with their repositories
|
||||
*/
|
||||
private initializeServices(): void {
|
||||
this.reportsService = new ReportsService(this.dataSource);
|
||||
// Get repositories
|
||||
const definitionRepository = this.dataSource.getRepository(ReportDefinition);
|
||||
const executionRepository = this.dataSource.getRepository(ReportExecution);
|
||||
const scheduleRepository = this.dataSource.getRepository(ReportSchedule);
|
||||
const recipientRepository = this.dataSource.getRepository(ReportRecipient);
|
||||
const scheduleExecutionRepository = this.dataSource.getRepository(ScheduleExecution);
|
||||
const customReportRepository = this.dataSource.getRepository(CustomReport);
|
||||
const dashboardRepository = this.dataSource.getRepository(Dashboard);
|
||||
const widgetRepository = this.dataSource.getRepository(DashboardWidget);
|
||||
const queryRepository = this.dataSource.getRepository(WidgetQuery);
|
||||
|
||||
// Create services
|
||||
this.reportsService = new ReportsService(
|
||||
definitionRepository,
|
||||
scheduleRepository,
|
||||
recipientRepository,
|
||||
customReportRepository
|
||||
);
|
||||
|
||||
this.executionService = new ReportExecutionService(
|
||||
executionRepository,
|
||||
definitionRepository,
|
||||
this.dataSource
|
||||
);
|
||||
|
||||
this.schedulerService = new ReportSchedulerService(
|
||||
scheduleRepository,
|
||||
scheduleExecutionRepository,
|
||||
this.executionService
|
||||
);
|
||||
|
||||
this.dashboardsService = new DashboardsService(
|
||||
dashboardRepository,
|
||||
widgetRepository,
|
||||
queryRepository,
|
||||
this.dataSource
|
||||
);
|
||||
|
||||
// Legacy service for backwards compatibility
|
||||
this.legacyService = new LegacyReportsService(this.dataSource);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize routes with controllers
|
||||
*/
|
||||
private initializeRoutes(): void {
|
||||
const reportsController = new ReportsController(this.reportsService);
|
||||
const reportsController = new ReportsController(
|
||||
this.reportsService,
|
||||
this.executionService,
|
||||
this.schedulerService
|
||||
);
|
||||
|
||||
const dashboardsController = new DashboardsController(this.dashboardsService);
|
||||
const legacyController = new LegacyReportsController(this.legacyService);
|
||||
|
||||
// Mount routes
|
||||
this.router.use(`${this.basePath}/reports`, reportsController.router);
|
||||
this.router.use(`${this.basePath}/dashboards`, dashboardsController.router);
|
||||
this.router.use(`${this.basePath}/reports/legacy`, legacyController.router);
|
||||
}
|
||||
|
||||
// Reports module doesn't have its own entities - it uses data from other modules
|
||||
/**
|
||||
* Get all entities managed by this module
|
||||
*/
|
||||
static getEntities(): Function[] {
|
||||
return [];
|
||||
return [
|
||||
ReportDefinition,
|
||||
ReportExecution,
|
||||
ReportSchedule,
|
||||
ReportRecipient,
|
||||
ScheduleExecution,
|
||||
Dashboard,
|
||||
DashboardWidget,
|
||||
WidgetQuery,
|
||||
CustomReport,
|
||||
DataModelEntity,
|
||||
DataModelField,
|
||||
DataModelRelationship,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,96 +1,99 @@
|
||||
import { Router } from 'express';
|
||||
import { reportsController } from './reports.controller.js';
|
||||
import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js';
|
||||
import { DataSource } from 'typeorm';
|
||||
import {
|
||||
ReportDefinition,
|
||||
ReportExecution,
|
||||
ReportSchedule,
|
||||
ReportRecipient,
|
||||
ScheduleExecution,
|
||||
Dashboard,
|
||||
DashboardWidget,
|
||||
WidgetQuery,
|
||||
CustomReport,
|
||||
} from './entities';
|
||||
import {
|
||||
ReportsService,
|
||||
ReportExecutionService,
|
||||
ReportSchedulerService,
|
||||
DashboardsService,
|
||||
LegacyReportsService,
|
||||
} from './services';
|
||||
import { ReportsController, DashboardsController, LegacyReportsController } from './controllers';
|
||||
|
||||
const router = Router();
|
||||
/**
|
||||
* Creates and configures the reports routes
|
||||
*
|
||||
* Routes:
|
||||
* - /api/v1/reports/definitions/* - Report definitions CRUD
|
||||
* - /api/v1/reports/execute - Execute a report
|
||||
* - /api/v1/reports/executions/* - Execution history
|
||||
* - /api/v1/reports/schedules/* - Scheduled reports
|
||||
* - /api/v1/reports/custom/* - Custom user reports
|
||||
* - /api/v1/dashboards/* - Dashboards and widgets
|
||||
* - /api/v1/reports/legacy/* - Legacy analytics (sales, inventory, financial)
|
||||
*
|
||||
* @param dataSource - TypeORM DataSource instance
|
||||
* @returns Configured Express Router
|
||||
*/
|
||||
export function createReportsRoutes(dataSource: DataSource): Router {
|
||||
const router = Router();
|
||||
|
||||
// All routes require authentication
|
||||
router.use(authenticate);
|
||||
// Initialize repositories
|
||||
const definitionRepository = dataSource.getRepository(ReportDefinition);
|
||||
const executionRepository = dataSource.getRepository(ReportExecution);
|
||||
const scheduleRepository = dataSource.getRepository(ReportSchedule);
|
||||
const recipientRepository = dataSource.getRepository(ReportRecipient);
|
||||
const scheduleExecutionRepository = dataSource.getRepository(ScheduleExecution);
|
||||
const customReportRepository = dataSource.getRepository(CustomReport);
|
||||
const dashboardRepository = dataSource.getRepository(Dashboard);
|
||||
const widgetRepository = dataSource.getRepository(DashboardWidget);
|
||||
const queryRepository = dataSource.getRepository(WidgetQuery);
|
||||
|
||||
// ============================================================================
|
||||
// QUICK REPORTS (direct access without execution record)
|
||||
// ============================================================================
|
||||
// Initialize services
|
||||
const reportsService = new ReportsService(
|
||||
definitionRepository,
|
||||
scheduleRepository,
|
||||
recipientRepository,
|
||||
customReportRepository
|
||||
);
|
||||
|
||||
router.get('/quick/trial-balance',
|
||||
requireRoles('admin', 'manager', 'accountant', 'super_admin'),
|
||||
(req, res, next) => reportsController.getTrialBalance(req, res, next)
|
||||
);
|
||||
const executionService = new ReportExecutionService(
|
||||
executionRepository,
|
||||
definitionRepository,
|
||||
dataSource
|
||||
);
|
||||
|
||||
router.get('/quick/general-ledger',
|
||||
requireRoles('admin', 'manager', 'accountant', 'super_admin'),
|
||||
(req, res, next) => reportsController.getGeneralLedger(req, res, next)
|
||||
);
|
||||
const schedulerService = new ReportSchedulerService(
|
||||
scheduleRepository,
|
||||
scheduleExecutionRepository,
|
||||
executionService
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// DEFINITIONS
|
||||
// ============================================================================
|
||||
const dashboardsService = new DashboardsService(
|
||||
dashboardRepository,
|
||||
widgetRepository,
|
||||
queryRepository,
|
||||
dataSource
|
||||
);
|
||||
|
||||
// List all report definitions
|
||||
router.get('/definitions',
|
||||
requireRoles('admin', 'manager', 'accountant', 'super_admin'),
|
||||
(req, res, next) => reportsController.findAllDefinitions(req, res, next)
|
||||
);
|
||||
const legacyService = new LegacyReportsService(dataSource);
|
||||
|
||||
// Get specific definition
|
||||
router.get('/definitions/:id',
|
||||
requireRoles('admin', 'manager', 'accountant', 'super_admin'),
|
||||
(req, res, next) => reportsController.findDefinitionById(req, res, next)
|
||||
);
|
||||
// Initialize controllers
|
||||
const reportsController = new ReportsController(
|
||||
reportsService,
|
||||
executionService,
|
||||
schedulerService
|
||||
);
|
||||
|
||||
// Create custom definition (admin only)
|
||||
router.post('/definitions',
|
||||
requireRoles('admin', 'super_admin'),
|
||||
(req, res, next) => reportsController.createDefinition(req, res, next)
|
||||
);
|
||||
const dashboardsController = new DashboardsController(dashboardsService);
|
||||
const legacyController = new LegacyReportsController(legacyService);
|
||||
|
||||
// ============================================================================
|
||||
// EXECUTIONS
|
||||
// ============================================================================
|
||||
// Mount routes
|
||||
router.use('/', reportsController.router);
|
||||
router.use('/dashboards', dashboardsController.router);
|
||||
router.use('/legacy', legacyController.router);
|
||||
|
||||
// Execute a report
|
||||
router.post('/execute',
|
||||
requireRoles('admin', 'manager', 'accountant', 'super_admin'),
|
||||
(req, res, next) => reportsController.executeReport(req, res, next)
|
||||
);
|
||||
return router;
|
||||
}
|
||||
|
||||
// Get recent executions
|
||||
router.get('/executions',
|
||||
requireRoles('admin', 'manager', 'accountant', 'super_admin'),
|
||||
(req, res, next) => reportsController.findRecentExecutions(req, res, next)
|
||||
);
|
||||
|
||||
// Get specific execution
|
||||
router.get('/executions/:id',
|
||||
requireRoles('admin', 'manager', 'accountant', 'super_admin'),
|
||||
(req, res, next) => reportsController.findExecutionById(req, res, next)
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// SCHEDULES
|
||||
// ============================================================================
|
||||
|
||||
// List schedules
|
||||
router.get('/schedules',
|
||||
requireRoles('admin', 'manager', 'super_admin'),
|
||||
(req, res, next) => reportsController.findAllSchedules(req, res, next)
|
||||
);
|
||||
|
||||
// Create schedule
|
||||
router.post('/schedules',
|
||||
requireRoles('admin', 'super_admin'),
|
||||
(req, res, next) => reportsController.createSchedule(req, res, next)
|
||||
);
|
||||
|
||||
// Toggle schedule
|
||||
router.patch('/schedules/:id/toggle',
|
||||
requireRoles('admin', 'super_admin'),
|
||||
(req, res, next) => reportsController.toggleSchedule(req, res, next)
|
||||
);
|
||||
|
||||
// Delete schedule
|
||||
router.delete('/schedules/:id',
|
||||
requireRoles('admin', 'super_admin'),
|
||||
(req, res, next) => reportsController.deleteSchedule(req, res, next)
|
||||
);
|
||||
|
||||
export default router;
|
||||
export default createReportsRoutes;
|
||||
|
||||
468
src/modules/reports/services/dashboards.service.ts
Normal file
468
src/modules/reports/services/dashboards.service.ts
Normal file
@ -0,0 +1,468 @@
|
||||
import { Repository, ILike, FindOptionsWhere, DataSource } from 'typeorm';
|
||||
import {
|
||||
Dashboard,
|
||||
DashboardWidget,
|
||||
WidgetQuery,
|
||||
WidgetType,
|
||||
} from '../entities';
|
||||
import {
|
||||
CreateDashboardDto,
|
||||
UpdateDashboardDto,
|
||||
CreateDashboardWidgetDto,
|
||||
UpdateDashboardWidgetDto,
|
||||
CreateWidgetQueryDto,
|
||||
UpdateWidgetQueryDto,
|
||||
UpdateWidgetPositionsDto,
|
||||
DashboardFiltersDto,
|
||||
} from '../dto';
|
||||
import { NotFoundError, ValidationError, ForbiddenError } from '../../../shared/errors/index.js';
|
||||
import { logger } from '../../../shared/utils/logger.js';
|
||||
|
||||
/**
|
||||
* DashboardsService
|
||||
*
|
||||
* Manages dashboards, widgets, and widget queries.
|
||||
*/
|
||||
export class DashboardsService {
|
||||
constructor(
|
||||
private readonly dashboardRepository: Repository<Dashboard>,
|
||||
private readonly widgetRepository: Repository<DashboardWidget>,
|
||||
private readonly queryRepository: Repository<WidgetQuery>,
|
||||
private readonly dataSource: DataSource
|
||||
) {}
|
||||
|
||||
// ==================== DASHBOARDS ====================
|
||||
|
||||
/**
|
||||
* Get all dashboards for a tenant
|
||||
*/
|
||||
async findAllDashboards(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
filters: DashboardFiltersDto = {}
|
||||
): Promise<{ data: Dashboard[]; total: number }> {
|
||||
const { ownerId, isDefault, isPublic, isActive, search, page = 1, limit = 20 } = filters;
|
||||
|
||||
const queryBuilder = this.dashboardRepository
|
||||
.createQueryBuilder('dashboard')
|
||||
.where('dashboard.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('(dashboard.owner_id = :userId OR dashboard.is_public = true)', { userId });
|
||||
|
||||
if (ownerId) {
|
||||
queryBuilder.andWhere('dashboard.owner_id = :ownerId', { ownerId });
|
||||
}
|
||||
|
||||
if (isDefault !== undefined) {
|
||||
queryBuilder.andWhere('dashboard.is_default = :isDefault', { isDefault });
|
||||
}
|
||||
|
||||
if (isPublic !== undefined) {
|
||||
queryBuilder.andWhere('dashboard.is_public = :isPublic', { isPublic });
|
||||
}
|
||||
|
||||
if (isActive !== undefined) {
|
||||
queryBuilder.andWhere('dashboard.is_active = :isActive', { isActive });
|
||||
}
|
||||
|
||||
if (search) {
|
||||
queryBuilder.andWhere(
|
||||
'(dashboard.name ILIKE :search OR dashboard.description ILIKE :search)',
|
||||
{ search: `%${search}%` }
|
||||
);
|
||||
}
|
||||
|
||||
const total = await queryBuilder.getCount();
|
||||
|
||||
const data = await queryBuilder
|
||||
.orderBy('dashboard.is_default', 'DESC')
|
||||
.addOrderBy('dashboard.name', 'ASC')
|
||||
.skip((page - 1) * limit)
|
||||
.take(limit)
|
||||
.getMany();
|
||||
|
||||
return { data, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a dashboard by ID with widgets
|
||||
*/
|
||||
async findDashboardById(id: string, tenantId: string, userId: string): Promise<Dashboard> {
|
||||
const dashboard = await this.dashboardRepository.findOne({
|
||||
where: { id, tenantId },
|
||||
relations: ['widgets', 'widgets.queries'],
|
||||
});
|
||||
|
||||
if (!dashboard) {
|
||||
throw new NotFoundError('Dashboard not found');
|
||||
}
|
||||
|
||||
// Check access
|
||||
if (!dashboard.isPublic && dashboard.ownerId !== userId) {
|
||||
throw new ForbiddenError('Access denied to this dashboard');
|
||||
}
|
||||
|
||||
// Sort widgets by position
|
||||
if (dashboard.widgets) {
|
||||
dashboard.widgets.sort((a, b) => {
|
||||
if (a.positionY !== b.positionY) return a.positionY - b.positionY;
|
||||
return a.positionX - b.positionX;
|
||||
});
|
||||
}
|
||||
|
||||
return dashboard;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default dashboard for tenant
|
||||
*/
|
||||
async getDefaultDashboard(tenantId: string, userId: string): Promise<Dashboard | null> {
|
||||
return this.dashboardRepository.findOne({
|
||||
where: { tenantId, isDefault: true, isActive: true },
|
||||
relations: ['widgets', 'widgets.queries'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new dashboard
|
||||
*/
|
||||
async createDashboard(
|
||||
dto: CreateDashboardDto,
|
||||
tenantId: string,
|
||||
ownerId: string
|
||||
): Promise<Dashboard> {
|
||||
// If setting as default, unset other defaults
|
||||
if (dto.isDefault) {
|
||||
await this.dashboardRepository.update(
|
||||
{ tenantId, isDefault: true },
|
||||
{ isDefault: false }
|
||||
);
|
||||
}
|
||||
|
||||
const dashboard = this.dashboardRepository.create({
|
||||
...dto,
|
||||
tenantId,
|
||||
ownerId,
|
||||
});
|
||||
|
||||
const saved = await this.dashboardRepository.save(dashboard);
|
||||
logger.info('Dashboard created', { id: saved.id, name: dto.name, tenantId, ownerId });
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a dashboard
|
||||
*/
|
||||
async updateDashboard(
|
||||
id: string,
|
||||
dto: UpdateDashboardDto,
|
||||
tenantId: string,
|
||||
userId: string
|
||||
): Promise<Dashboard> {
|
||||
const dashboard = await this.findDashboardById(id, tenantId, userId);
|
||||
|
||||
if (dashboard.ownerId !== userId) {
|
||||
throw new ForbiddenError('Only the owner can update this dashboard');
|
||||
}
|
||||
|
||||
// If setting as default, unset other defaults
|
||||
if (dto.isDefault && !dashboard.isDefault) {
|
||||
await this.dashboardRepository.update(
|
||||
{ tenantId, isDefault: true },
|
||||
{ isDefault: false }
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(dashboard, dto);
|
||||
|
||||
const updated = await this.dashboardRepository.save(dashboard);
|
||||
logger.info('Dashboard updated', { id, tenantId });
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a dashboard
|
||||
*/
|
||||
async deleteDashboard(id: string, tenantId: string, userId: string): Promise<void> {
|
||||
const dashboard = await this.findDashboardById(id, tenantId, userId);
|
||||
|
||||
if (dashboard.ownerId !== userId) {
|
||||
throw new ForbiddenError('Only the owner can delete this dashboard');
|
||||
}
|
||||
|
||||
await this.dashboardRepository.remove(dashboard);
|
||||
logger.info('Dashboard deleted', { id, tenantId });
|
||||
}
|
||||
|
||||
// ==================== WIDGETS ====================
|
||||
|
||||
/**
|
||||
* Get widgets for a dashboard
|
||||
*/
|
||||
async findWidgetsByDashboard(dashboardId: string): Promise<DashboardWidget[]> {
|
||||
return this.widgetRepository.find({
|
||||
where: { dashboardId, isActive: true },
|
||||
relations: ['queries'],
|
||||
order: { sortOrder: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a widget by ID
|
||||
*/
|
||||
async findWidgetById(id: string): Promise<DashboardWidget> {
|
||||
const widget = await this.widgetRepository.findOne({
|
||||
where: { id },
|
||||
relations: ['queries', 'dashboard'],
|
||||
});
|
||||
|
||||
if (!widget) {
|
||||
throw new NotFoundError('Widget not found');
|
||||
}
|
||||
|
||||
return widget;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a widget
|
||||
*/
|
||||
async createWidget(
|
||||
dto: CreateDashboardWidgetDto,
|
||||
tenantId: string,
|
||||
userId: string
|
||||
): Promise<DashboardWidget> {
|
||||
// Verify dashboard access
|
||||
await this.findDashboardById(dto.dashboardId, tenantId, userId);
|
||||
|
||||
const widget = this.widgetRepository.create(dto);
|
||||
const saved = await this.widgetRepository.save(widget);
|
||||
logger.info('Widget created', { id: saved.id, dashboardId: dto.dashboardId });
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a widget
|
||||
*/
|
||||
async updateWidget(
|
||||
id: string,
|
||||
dto: UpdateDashboardWidgetDto,
|
||||
tenantId: string,
|
||||
userId: string
|
||||
): Promise<DashboardWidget> {
|
||||
const widget = await this.findWidgetById(id);
|
||||
|
||||
// Verify dashboard access
|
||||
await this.findDashboardById(widget.dashboardId, tenantId, userId);
|
||||
|
||||
Object.assign(widget, dto);
|
||||
|
||||
const updated = await this.widgetRepository.save(widget);
|
||||
logger.info('Widget updated', { id });
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update multiple widget positions
|
||||
*/
|
||||
async updateWidgetPositions(
|
||||
dto: UpdateWidgetPositionsDto,
|
||||
tenantId: string,
|
||||
userId: string
|
||||
): Promise<void> {
|
||||
for (const widgetPos of dto.widgets) {
|
||||
await this.widgetRepository.update(widgetPos.id, {
|
||||
positionX: widgetPos.positionX,
|
||||
positionY: widgetPos.positionY,
|
||||
width: widgetPos.width,
|
||||
height: widgetPos.height,
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('Widget positions updated', { count: dto.widgets.length });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a widget
|
||||
*/
|
||||
async deleteWidget(id: string, tenantId: string, userId: string): Promise<void> {
|
||||
const widget = await this.findWidgetById(id);
|
||||
|
||||
// Verify dashboard access
|
||||
await this.findDashboardById(widget.dashboardId, tenantId, userId);
|
||||
|
||||
await this.widgetRepository.remove(widget);
|
||||
logger.info('Widget deleted', { id });
|
||||
}
|
||||
|
||||
// ==================== WIDGET QUERIES ====================
|
||||
|
||||
/**
|
||||
* Create a widget query
|
||||
*/
|
||||
async createWidgetQuery(dto: CreateWidgetQueryDto): Promise<WidgetQuery> {
|
||||
const query = this.queryRepository.create(dto);
|
||||
const saved = await this.queryRepository.save(query);
|
||||
logger.info('Widget query created', { id: saved.id, widgetId: dto.widgetId });
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a widget query
|
||||
*/
|
||||
async updateWidgetQuery(id: string, dto: UpdateWidgetQueryDto): Promise<WidgetQuery> {
|
||||
const query = await this.queryRepository.findOne({ where: { id } });
|
||||
|
||||
if (!query) {
|
||||
throw new NotFoundError('Widget query not found');
|
||||
}
|
||||
|
||||
Object.assign(query, dto);
|
||||
|
||||
const updated = await this.queryRepository.save(query);
|
||||
logger.info('Widget query updated', { id });
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a widget query
|
||||
*/
|
||||
async deleteWidgetQuery(id: string): Promise<void> {
|
||||
const query = await this.queryRepository.findOne({ where: { id } });
|
||||
|
||||
if (!query) {
|
||||
throw new NotFoundError('Widget query not found');
|
||||
}
|
||||
|
||||
await this.queryRepository.remove(query);
|
||||
logger.info('Widget query deleted', { id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a widget query and return data
|
||||
*/
|
||||
async executeWidgetQuery(
|
||||
queryId: string,
|
||||
tenantId: string,
|
||||
parameters: Record<string, any> = {}
|
||||
): Promise<any> {
|
||||
const query = await this.queryRepository.findOne({
|
||||
where: { id: queryId },
|
||||
relations: ['widget'],
|
||||
});
|
||||
|
||||
if (!query) {
|
||||
throw new NotFoundError('Widget query not found');
|
||||
}
|
||||
|
||||
// Check cache
|
||||
if (query.lastCachedAt && query.cacheTtlSeconds) {
|
||||
const cacheAge = (Date.now() - query.lastCachedAt.getTime()) / 1000;
|
||||
if (cacheAge < query.cacheTtlSeconds) {
|
||||
// Return cached data from resultMapping if available
|
||||
logger.debug('Returning cached widget data', { queryId });
|
||||
}
|
||||
}
|
||||
|
||||
let result: any;
|
||||
|
||||
if (query.queryFunction) {
|
||||
result = await this.dataSource.query(
|
||||
`SELECT * FROM ${query.queryFunction}($1)`,
|
||||
[tenantId]
|
||||
);
|
||||
} else if (query.queryText) {
|
||||
const safeQuery = query.queryText.replace(/\{\{tenant_id\}\}/g, '$1');
|
||||
result = await this.dataSource.query(safeQuery, [tenantId]);
|
||||
} else {
|
||||
throw new ValidationError('Widget query has no query defined');
|
||||
}
|
||||
|
||||
// Update cache timestamp
|
||||
await this.queryRepository.update(queryId, {
|
||||
lastCachedAt: new Date(),
|
||||
});
|
||||
|
||||
// Apply result mapping if defined
|
||||
if (query.resultMapping && Object.keys(query.resultMapping).length > 0) {
|
||||
result = this.applyResultMapping(result, query.resultMapping);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply result mapping to transform query results
|
||||
*/
|
||||
private applyResultMapping(
|
||||
data: any[],
|
||||
mapping: Record<string, any>
|
||||
): any {
|
||||
// Simple mapping implementation
|
||||
if (mapping.type === 'single') {
|
||||
return data[0] || null;
|
||||
}
|
||||
|
||||
if (mapping.type === 'aggregate') {
|
||||
const field = mapping.field;
|
||||
const aggregation = mapping.aggregation;
|
||||
|
||||
if (aggregation === 'sum') {
|
||||
return data.reduce((sum, row) => sum + (parseFloat(row[field]) || 0), 0);
|
||||
}
|
||||
if (aggregation === 'count') {
|
||||
return data.length;
|
||||
}
|
||||
if (aggregation === 'avg') {
|
||||
const sum = data.reduce((s, row) => s + (parseFloat(row[field]) || 0), 0);
|
||||
return data.length > 0 ? sum / data.length : 0;
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all widget data for a dashboard
|
||||
*/
|
||||
async getDashboardData(
|
||||
dashboardId: string,
|
||||
tenantId: string,
|
||||
userId: string
|
||||
): Promise<Record<string, any>> {
|
||||
const dashboard = await this.findDashboardById(dashboardId, tenantId, userId);
|
||||
const data: Record<string, any> = {};
|
||||
|
||||
for (const widget of dashboard.widgets || []) {
|
||||
if (!widget.isActive) continue;
|
||||
|
||||
const widgetData: any[] = [];
|
||||
|
||||
for (const query of widget.queries || []) {
|
||||
try {
|
||||
const result = await this.executeWidgetQuery(query.id, tenantId);
|
||||
widgetData.push({ name: query.name, data: result });
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to execute widget query', {
|
||||
queryId: query.id,
|
||||
widgetId: widget.id,
|
||||
error: error.message,
|
||||
});
|
||||
widgetData.push({ name: query.name, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
data[widget.id] = {
|
||||
title: widget.title,
|
||||
type: widget.widgetType,
|
||||
queries: widgetData,
|
||||
};
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@ -1,3 +1,10 @@
|
||||
// Re-export new services
|
||||
export { ReportsService } from './reports.service';
|
||||
export { ReportExecutionService } from './report-execution.service';
|
||||
export { ReportSchedulerService } from './report-scheduler.service';
|
||||
export { DashboardsService } from './dashboards.service';
|
||||
|
||||
// Legacy types and service (kept for backwards compatibility)
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
export interface ReportDateRange {
|
||||
@ -50,7 +57,7 @@ export interface FinancialSummary {
|
||||
margin: number;
|
||||
}
|
||||
|
||||
export class ReportsService {
|
||||
export class LegacyReportsService {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
// ==================== Sales Reports ====================
|
||||
|
||||
376
src/modules/reports/services/report-execution.service.ts
Normal file
376
src/modules/reports/services/report-execution.service.ts
Normal file
@ -0,0 +1,376 @@
|
||||
import { Repository, LessThanOrEqual } from 'typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import {
|
||||
ReportDefinition,
|
||||
ReportExecution,
|
||||
ExecutionStatus,
|
||||
ExportFormat,
|
||||
} from '../entities';
|
||||
import {
|
||||
ExecuteReportDto,
|
||||
ReportExecutionFiltersDto,
|
||||
} from '../dto';
|
||||
import { NotFoundError, ValidationError } from '../../../shared/errors/index.js';
|
||||
import { logger } from '../../../shared/utils/logger.js';
|
||||
|
||||
/**
|
||||
* ReportExecutionService
|
||||
*
|
||||
* Handles report execution, result storage, and execution history.
|
||||
*/
|
||||
export class ReportExecutionService {
|
||||
constructor(
|
||||
private readonly executionRepository: Repository<ReportExecution>,
|
||||
private readonly definitionRepository: Repository<ReportDefinition>,
|
||||
private readonly dataSource: DataSource
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Execute a report
|
||||
*/
|
||||
async executeReport(
|
||||
dto: ExecuteReportDto,
|
||||
tenantId: string,
|
||||
executedBy: string
|
||||
): Promise<ReportExecution> {
|
||||
// Get definition
|
||||
const definition = await this.definitionRepository.findOne({
|
||||
where: { id: dto.reportDefinitionId, tenantId },
|
||||
});
|
||||
|
||||
if (!definition) {
|
||||
throw new NotFoundError('Report definition not found');
|
||||
}
|
||||
|
||||
// Validate parameters
|
||||
this.validateParameters(dto.parameters, definition.parametersSchema);
|
||||
|
||||
// Create execution record
|
||||
const execution = this.executionRepository.create({
|
||||
tenantId,
|
||||
reportDefinitionId: dto.reportDefinitionId,
|
||||
executedBy,
|
||||
status: ExecutionStatus.PENDING,
|
||||
parameters: dto.parameters,
|
||||
exportFormat: dto.exportFormat || null,
|
||||
});
|
||||
|
||||
const saved = await this.executionRepository.save(execution);
|
||||
|
||||
// Execute asynchronously
|
||||
this.runExecution(saved.id, definition, dto.parameters, tenantId)
|
||||
.catch(err => logger.error('Report execution failed', { executionId: saved.id, error: err }));
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the actual report execution
|
||||
*/
|
||||
private async runExecution(
|
||||
executionId: string,
|
||||
definition: ReportDefinition,
|
||||
parameters: Record<string, any>,
|
||||
tenantId: string
|
||||
): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// Mark as running
|
||||
await this.executionRepository.update(executionId, {
|
||||
status: ExecutionStatus.RUNNING,
|
||||
startedAt: new Date(),
|
||||
});
|
||||
|
||||
let resultData: any[];
|
||||
let rowCount = 0;
|
||||
|
||||
if (definition.queryFunction) {
|
||||
// Execute PostgreSQL function
|
||||
const funcParams = this.buildFunctionParams(definition.queryFunction, parameters, tenantId);
|
||||
resultData = await this.dataSource.query(
|
||||
`SELECT * FROM ${definition.queryFunction}(${funcParams.placeholders})`,
|
||||
funcParams.values
|
||||
);
|
||||
rowCount = resultData.length;
|
||||
} else if (definition.baseQuery) {
|
||||
// Execute parameterized query
|
||||
const safeQuery = this.buildSafeQuery(definition.baseQuery, parameters, tenantId);
|
||||
resultData = await this.dataSource.query(safeQuery.sql, safeQuery.values);
|
||||
rowCount = resultData.length;
|
||||
} else {
|
||||
throw new Error('Report definition has no query or function defined');
|
||||
}
|
||||
|
||||
const executionTimeMs = Date.now() - startTime;
|
||||
|
||||
// Calculate summary
|
||||
const resultSummary = this.calculateSummary(resultData, definition.totalsConfig);
|
||||
|
||||
// Update with results
|
||||
await this.executionRepository.update(executionId, {
|
||||
status: ExecutionStatus.COMPLETED,
|
||||
completedAt: new Date(),
|
||||
executionTimeMs,
|
||||
rowCount,
|
||||
resultData,
|
||||
resultSummary,
|
||||
});
|
||||
|
||||
logger.info('Report execution completed', { executionId, rowCount, executionTimeMs });
|
||||
|
||||
} catch (error: any) {
|
||||
const executionTimeMs = Date.now() - startTime;
|
||||
|
||||
await this.executionRepository.update(executionId, {
|
||||
status: ExecutionStatus.FAILED,
|
||||
completedAt: new Date(),
|
||||
executionTimeMs,
|
||||
errorMessage: error.message,
|
||||
errorDetails: { stack: error.stack },
|
||||
});
|
||||
|
||||
logger.error('Report execution failed', { executionId, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get execution by ID
|
||||
*/
|
||||
async findById(id: string, tenantId: string): Promise<ReportExecution> {
|
||||
const execution = await this.executionRepository.findOne({
|
||||
where: { id, tenantId },
|
||||
relations: ['reportDefinition'],
|
||||
});
|
||||
|
||||
if (!execution) {
|
||||
throw new NotFoundError('Report execution not found');
|
||||
}
|
||||
|
||||
return execution;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get execution history with filtering
|
||||
*/
|
||||
async findAll(
|
||||
tenantId: string,
|
||||
filters: ReportExecutionFiltersDto = {}
|
||||
): Promise<{ data: ReportExecution[]; total: number }> {
|
||||
const {
|
||||
reportDefinitionId,
|
||||
status,
|
||||
executedBy,
|
||||
exportFormat,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
page = 1,
|
||||
limit = 20,
|
||||
} = filters;
|
||||
|
||||
const queryBuilder = this.executionRepository
|
||||
.createQueryBuilder('exec')
|
||||
.leftJoinAndSelect('exec.reportDefinition', 'definition')
|
||||
.where('exec.tenant_id = :tenantId', { tenantId });
|
||||
|
||||
if (reportDefinitionId) {
|
||||
queryBuilder.andWhere('exec.report_definition_id = :reportDefinitionId', { reportDefinitionId });
|
||||
}
|
||||
|
||||
if (status) {
|
||||
queryBuilder.andWhere('exec.status = :status', { status });
|
||||
}
|
||||
|
||||
if (executedBy) {
|
||||
queryBuilder.andWhere('exec.executed_by = :executedBy', { executedBy });
|
||||
}
|
||||
|
||||
if (exportFormat) {
|
||||
queryBuilder.andWhere('exec.export_format = :exportFormat', { exportFormat });
|
||||
}
|
||||
|
||||
if (dateFrom) {
|
||||
queryBuilder.andWhere('exec.created_at >= :dateFrom', { dateFrom: new Date(dateFrom) });
|
||||
}
|
||||
|
||||
if (dateTo) {
|
||||
queryBuilder.andWhere('exec.created_at <= :dateTo', { dateTo: new Date(dateTo) });
|
||||
}
|
||||
|
||||
const total = await queryBuilder.getCount();
|
||||
|
||||
const data = await queryBuilder
|
||||
.orderBy('exec.created_at', 'DESC')
|
||||
.skip((page - 1) * limit)
|
||||
.take(limit)
|
||||
.getMany();
|
||||
|
||||
return { data, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent executions for a definition
|
||||
*/
|
||||
async findRecentByDefinition(
|
||||
definitionId: string,
|
||||
tenantId: string,
|
||||
limit: number = 10
|
||||
): Promise<ReportExecution[]> {
|
||||
return this.executionRepository.find({
|
||||
where: { reportDefinitionId: definitionId, tenantId },
|
||||
order: { createdAt: 'DESC' },
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a pending or running execution
|
||||
*/
|
||||
async cancelExecution(id: string, tenantId: string): Promise<ReportExecution> {
|
||||
const execution = await this.findById(id, tenantId);
|
||||
|
||||
if (execution.status !== ExecutionStatus.PENDING && execution.status !== ExecutionStatus.RUNNING) {
|
||||
throw new ValidationError('Can only cancel pending or running executions');
|
||||
}
|
||||
|
||||
execution.status = ExecutionStatus.CANCELLED;
|
||||
execution.completedAt = new Date();
|
||||
|
||||
const updated = await this.executionRepository.save(execution);
|
||||
logger.info('Report execution cancelled', { id, tenantId });
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete old executions (cleanup)
|
||||
*/
|
||||
async deleteOldExecutions(tenantId: string, olderThanDays: number = 30): Promise<number> {
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - olderThanDays);
|
||||
|
||||
const result = await this.executionRepository.delete({
|
||||
tenantId,
|
||||
createdAt: LessThanOrEqual(cutoffDate),
|
||||
});
|
||||
|
||||
const deletedCount = result.affected || 0;
|
||||
logger.info('Old report executions deleted', { tenantId, deletedCount, olderThanDays });
|
||||
|
||||
return deletedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate parameters against schema
|
||||
*/
|
||||
private validateParameters(
|
||||
params: Record<string, any>,
|
||||
schema: Record<string, any>[]
|
||||
): void {
|
||||
for (const paramDef of schema) {
|
||||
const value = params[paramDef.name];
|
||||
|
||||
if (paramDef.required && (value === undefined || value === null)) {
|
||||
throw new ValidationError(`Required parameter: ${paramDef.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build function parameters
|
||||
*/
|
||||
private buildFunctionParams(
|
||||
functionName: string,
|
||||
parameters: Record<string, any>,
|
||||
tenantId: string
|
||||
): { placeholders: string; values: any[] } {
|
||||
const values: any[] = [tenantId];
|
||||
let idx = 2;
|
||||
|
||||
// Common patterns for financial reports
|
||||
if (functionName.includes('trial_balance') || functionName.includes('balance_sheet')) {
|
||||
values.push(
|
||||
parameters.company_id || null,
|
||||
parameters.date_from,
|
||||
parameters.date_to,
|
||||
parameters.include_zero || false
|
||||
);
|
||||
return { placeholders: '$1, $2, $3, $4, $5', values };
|
||||
}
|
||||
|
||||
if (functionName.includes('general_ledger')) {
|
||||
values.push(
|
||||
parameters.company_id || null,
|
||||
parameters.account_id,
|
||||
parameters.date_from,
|
||||
parameters.date_to
|
||||
);
|
||||
return { placeholders: '$1, $2, $3, $4, $5', values };
|
||||
}
|
||||
|
||||
if (functionName.includes('income_statement')) {
|
||||
values.push(
|
||||
parameters.company_id || null,
|
||||
parameters.date_from,
|
||||
parameters.date_to
|
||||
);
|
||||
return { placeholders: '$1, $2, $3, $4', values };
|
||||
}
|
||||
|
||||
// Default: only tenant_id
|
||||
return { placeholders: '$1', values };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build safe parameterized query
|
||||
*/
|
||||
private buildSafeQuery(
|
||||
baseQuery: string,
|
||||
parameters: Record<string, any>,
|
||||
tenantId: string
|
||||
): { sql: string; values: any[] } {
|
||||
let sql = baseQuery;
|
||||
const values: any[] = [tenantId];
|
||||
let idx = 2;
|
||||
|
||||
// Replace {{tenant_id}} with $1
|
||||
sql = sql.replace(/\{\{tenant_id\}\}/g, '$1');
|
||||
|
||||
// Replace other parameters
|
||||
for (const [key, value] of Object.entries(parameters)) {
|
||||
const placeholder = `{{${key}}}`;
|
||||
if (sql.includes(placeholder)) {
|
||||
sql = sql.replace(new RegExp(placeholder.replace(/[{}]/g, '\\$&'), 'g'), `$${idx}`);
|
||||
values.push(value);
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
|
||||
return { sql, values };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate summary totals from data
|
||||
*/
|
||||
private calculateSummary(
|
||||
data: any[],
|
||||
totalsConfig: Record<string, any>[]
|
||||
): Record<string, any> {
|
||||
if (!totalsConfig || totalsConfig.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const summary: Record<string, number> = {};
|
||||
|
||||
for (const config of totalsConfig) {
|
||||
if (config.column && config.aggregation === 'sum') {
|
||||
summary[config.column] = data.reduce(
|
||||
(sum, row) => sum + (parseFloat(row[config.column]) || 0),
|
||||
0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
}
|
||||
273
src/modules/reports/services/report-scheduler.service.ts
Normal file
273
src/modules/reports/services/report-scheduler.service.ts
Normal file
@ -0,0 +1,273 @@
|
||||
import { Repository } from 'typeorm';
|
||||
import {
|
||||
ReportSchedule,
|
||||
ScheduleExecution,
|
||||
ReportExecution,
|
||||
ExecutionStatus,
|
||||
} from '../entities';
|
||||
import { logger } from '../../../shared/utils/logger.js';
|
||||
import { ReportExecutionService } from './report-execution.service';
|
||||
|
||||
/**
|
||||
* ReportSchedulerService
|
||||
*
|
||||
* Handles scheduled report execution, delivery, and tracking.
|
||||
*/
|
||||
export class ReportSchedulerService {
|
||||
constructor(
|
||||
private readonly scheduleRepository: Repository<ReportSchedule>,
|
||||
private readonly scheduleExecutionRepository: Repository<ScheduleExecution>,
|
||||
private readonly reportExecutionService: ReportExecutionService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Process all due schedules
|
||||
*/
|
||||
async processDueSchedules(): Promise<void> {
|
||||
const dueSchedules = await this.scheduleRepository
|
||||
.createQueryBuilder('schedule')
|
||||
.where('schedule.is_active = true')
|
||||
.andWhere('schedule.next_run_at <= NOW()')
|
||||
.leftJoinAndSelect('schedule.reportDefinition', 'definition')
|
||||
.leftJoinAndSelect('schedule.recipients', 'recipients', 'recipients.is_active = true')
|
||||
.getMany();
|
||||
|
||||
logger.info('Processing due schedules', { count: dueSchedules.length });
|
||||
|
||||
for (const schedule of dueSchedules) {
|
||||
try {
|
||||
await this.executeSchedule(schedule);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to execute schedule', {
|
||||
scheduleId: schedule.id,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a specific schedule
|
||||
*/
|
||||
async executeSchedule(schedule: ReportSchedule): Promise<ScheduleExecution> {
|
||||
logger.info('Executing scheduled report', {
|
||||
scheduleId: schedule.id,
|
||||
name: schedule.name,
|
||||
});
|
||||
|
||||
let scheduleExecution: ScheduleExecution;
|
||||
let reportExecution: ReportExecution | null = null;
|
||||
|
||||
try {
|
||||
// Execute the report
|
||||
reportExecution = await this.reportExecutionService.executeReport(
|
||||
{
|
||||
reportDefinitionId: schedule.reportDefinitionId,
|
||||
parameters: schedule.parameters,
|
||||
exportFormat: schedule.exportFormat,
|
||||
},
|
||||
schedule.tenantId,
|
||||
schedule.createdBy || 'system'
|
||||
);
|
||||
|
||||
// Wait for completion (with timeout)
|
||||
const completedExecution = await this.waitForCompletion(reportExecution.id, schedule.tenantId);
|
||||
|
||||
// Create schedule execution record
|
||||
scheduleExecution = this.scheduleExecutionRepository.create({
|
||||
scheduleId: schedule.id,
|
||||
executionId: completedExecution.id,
|
||||
status: completedExecution.status,
|
||||
executedAt: new Date(),
|
||||
});
|
||||
|
||||
// Deliver if successful
|
||||
if (completedExecution.status === ExecutionStatus.COMPLETED) {
|
||||
const recipientsNotified = await this.deliverReport(schedule, completedExecution);
|
||||
scheduleExecution.recipientsNotified = recipientsNotified;
|
||||
scheduleExecution.deliveryStatus = { success: true, notified: recipientsNotified };
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
scheduleExecution = this.scheduleExecutionRepository.create({
|
||||
scheduleId: schedule.id,
|
||||
executionId: reportExecution?.id || null,
|
||||
status: ExecutionStatus.FAILED,
|
||||
errorMessage: error.message,
|
||||
executedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
// Save schedule execution
|
||||
const saved = await this.scheduleExecutionRepository.save(scheduleExecution);
|
||||
|
||||
// Update schedule with last run info
|
||||
await this.updateScheduleAfterRun(schedule, scheduleExecution);
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for report execution to complete
|
||||
*/
|
||||
private async waitForCompletion(
|
||||
executionId: string,
|
||||
tenantId: string,
|
||||
timeoutMs: number = 300000 // 5 minutes
|
||||
): Promise<ReportExecution> {
|
||||
const startTime = Date.now();
|
||||
const pollInterval = 2000; // 2 seconds
|
||||
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
const execution = await this.reportExecutionService.findById(executionId, tenantId);
|
||||
|
||||
if (
|
||||
execution.status === ExecutionStatus.COMPLETED ||
|
||||
execution.status === ExecutionStatus.FAILED ||
|
||||
execution.status === ExecutionStatus.CANCELLED
|
||||
) {
|
||||
return execution;
|
||||
}
|
||||
|
||||
// Wait before next poll
|
||||
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
||||
}
|
||||
|
||||
throw new Error('Report execution timed out');
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver report to recipients
|
||||
*/
|
||||
private async deliverReport(
|
||||
schedule: ReportSchedule,
|
||||
execution: ReportExecution
|
||||
): Promise<number> {
|
||||
const activeRecipients = schedule.recipients?.filter(r => r.isActive) || [];
|
||||
|
||||
if (activeRecipients.length === 0) {
|
||||
logger.warn('No active recipients for schedule', { scheduleId: schedule.id });
|
||||
return 0;
|
||||
}
|
||||
|
||||
let notifiedCount = 0;
|
||||
|
||||
switch (schedule.deliveryMethod) {
|
||||
case 'email':
|
||||
notifiedCount = await this.deliverByEmail(schedule, execution, activeRecipients);
|
||||
break;
|
||||
case 'storage':
|
||||
notifiedCount = await this.deliverToStorage(schedule, execution);
|
||||
break;
|
||||
case 'webhook':
|
||||
notifiedCount = await this.deliverByWebhook(schedule, execution);
|
||||
break;
|
||||
default:
|
||||
logger.debug('No delivery method configured', { scheduleId: schedule.id });
|
||||
}
|
||||
|
||||
return notifiedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver report by email
|
||||
*/
|
||||
private async deliverByEmail(
|
||||
schedule: ReportSchedule,
|
||||
execution: ReportExecution,
|
||||
recipients: any[]
|
||||
): Promise<number> {
|
||||
// TODO: Implement email delivery using notification service
|
||||
logger.info('Email delivery requested', {
|
||||
scheduleId: schedule.id,
|
||||
recipientCount: recipients.length,
|
||||
});
|
||||
|
||||
// Placeholder - would integrate with email service
|
||||
return recipients.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver report to storage
|
||||
*/
|
||||
private async deliverToStorage(
|
||||
schedule: ReportSchedule,
|
||||
execution: ReportExecution
|
||||
): Promise<number> {
|
||||
// TODO: Implement storage delivery
|
||||
logger.info('Storage delivery requested', { scheduleId: schedule.id });
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver report via webhook
|
||||
*/
|
||||
private async deliverByWebhook(
|
||||
schedule: ReportSchedule,
|
||||
execution: ReportExecution
|
||||
): Promise<number> {
|
||||
// TODO: Implement webhook delivery
|
||||
const webhookUrl = schedule.deliveryConfig?.webhookUrl;
|
||||
logger.info('Webhook delivery requested', { scheduleId: schedule.id, webhookUrl });
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update schedule after execution
|
||||
*/
|
||||
private async updateScheduleAfterRun(
|
||||
schedule: ReportSchedule,
|
||||
scheduleExecution: ScheduleExecution
|
||||
): Promise<void> {
|
||||
const nextRunAt = this.calculateNextRun(schedule.cronExpression, schedule.timezone);
|
||||
|
||||
await this.scheduleRepository.update(schedule.id, {
|
||||
lastRunAt: new Date(),
|
||||
lastRunStatus: scheduleExecution.status,
|
||||
nextRunAt,
|
||||
runCount: schedule.runCount + 1,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate next run time from cron expression
|
||||
*/
|
||||
private calculateNextRun(cronExpression: string, timezone: string): Date {
|
||||
// Simple implementation - would use a proper cron parser library
|
||||
// For now, default to next day at same time
|
||||
const nextRun = new Date();
|
||||
nextRun.setDate(nextRun.getDate() + 1);
|
||||
return nextRun;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get execution history for a schedule
|
||||
*/
|
||||
async getScheduleHistory(
|
||||
scheduleId: string,
|
||||
limit: number = 20
|
||||
): Promise<ScheduleExecution[]> {
|
||||
return this.scheduleExecutionRepository.find({
|
||||
where: { scheduleId },
|
||||
relations: ['execution'],
|
||||
order: { executedAt: 'DESC' },
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually trigger a schedule
|
||||
*/
|
||||
async triggerSchedule(scheduleId: string, tenantId: string): Promise<ScheduleExecution> {
|
||||
const schedule = await this.scheduleRepository.findOne({
|
||||
where: { id: scheduleId, tenantId },
|
||||
relations: ['reportDefinition', 'recipients'],
|
||||
});
|
||||
|
||||
if (!schedule) {
|
||||
throw new Error('Schedule not found');
|
||||
}
|
||||
|
||||
return this.executeSchedule(schedule);
|
||||
}
|
||||
}
|
||||
510
src/modules/reports/services/reports.service.ts
Normal file
510
src/modules/reports/services/reports.service.ts
Normal file
@ -0,0 +1,510 @@
|
||||
import { Repository, ILike, FindOptionsWhere, In } from 'typeorm';
|
||||
import {
|
||||
ReportDefinition,
|
||||
ReportExecution,
|
||||
ReportSchedule,
|
||||
ReportRecipient,
|
||||
CustomReport,
|
||||
ExecutionStatus,
|
||||
ReportType,
|
||||
} from '../entities';
|
||||
import {
|
||||
CreateReportDefinitionDto,
|
||||
UpdateReportDefinitionDto,
|
||||
CreateReportScheduleDto,
|
||||
UpdateReportScheduleDto,
|
||||
CreateReportRecipientDto,
|
||||
UpdateReportRecipientDto,
|
||||
CreateCustomReportDto,
|
||||
UpdateCustomReportDto,
|
||||
ReportDefinitionFiltersDto,
|
||||
ReportScheduleFiltersDto,
|
||||
CustomReportFiltersDto,
|
||||
} from '../dto';
|
||||
import { NotFoundError, ValidationError, ConflictError } from '../../../shared/errors/index.js';
|
||||
import { logger } from '../../../shared/utils/logger.js';
|
||||
|
||||
/**
|
||||
* ReportsService
|
||||
*
|
||||
* Manages report definitions, schedules, recipients, and custom reports.
|
||||
* Provides CRUD operations with multi-tenant isolation.
|
||||
*/
|
||||
export class ReportsService {
|
||||
constructor(
|
||||
private readonly definitionRepository: Repository<ReportDefinition>,
|
||||
private readonly scheduleRepository: Repository<ReportSchedule>,
|
||||
private readonly recipientRepository: Repository<ReportRecipient>,
|
||||
private readonly customReportRepository: Repository<CustomReport>
|
||||
) {}
|
||||
|
||||
// ==================== REPORT DEFINITIONS ====================
|
||||
|
||||
/**
|
||||
* Get all report definitions with optional filtering
|
||||
*/
|
||||
async findAllDefinitions(
|
||||
tenantId: string,
|
||||
filters: ReportDefinitionFiltersDto = {}
|
||||
): Promise<{ data: ReportDefinition[]; total: number }> {
|
||||
const { reportType, category, isPublic, isActive, search, page = 1, limit = 20 } = filters;
|
||||
|
||||
const where: FindOptionsWhere<ReportDefinition>[] = [];
|
||||
const baseWhere: FindOptionsWhere<ReportDefinition> = { tenantId };
|
||||
|
||||
if (reportType) {
|
||||
baseWhere.reportType = reportType;
|
||||
}
|
||||
|
||||
if (category) {
|
||||
baseWhere.category = category;
|
||||
}
|
||||
|
||||
if (isPublic !== undefined) {
|
||||
baseWhere.isPublic = isPublic;
|
||||
}
|
||||
|
||||
if (isActive !== undefined) {
|
||||
baseWhere.isActive = isActive;
|
||||
}
|
||||
|
||||
if (search) {
|
||||
where.push(
|
||||
{ ...baseWhere, name: ILike(`%${search}%`) },
|
||||
{ ...baseWhere, code: ILike(`%${search}%`) },
|
||||
{ ...baseWhere, description: ILike(`%${search}%`) }
|
||||
);
|
||||
} else {
|
||||
where.push(baseWhere);
|
||||
}
|
||||
|
||||
const [data, total] = await this.definitionRepository.findAndCount({
|
||||
where: where.length > 0 ? where : undefined,
|
||||
order: { category: 'ASC', name: 'ASC' },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
});
|
||||
|
||||
return { data, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a report definition by ID
|
||||
*/
|
||||
async findDefinitionById(id: string, tenantId: string): Promise<ReportDefinition> {
|
||||
const definition = await this.definitionRepository.findOne({
|
||||
where: { id, tenantId },
|
||||
});
|
||||
|
||||
if (!definition) {
|
||||
throw new NotFoundError('Report definition not found');
|
||||
}
|
||||
|
||||
return definition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a report definition by code
|
||||
*/
|
||||
async findDefinitionByCode(code: string, tenantId: string): Promise<ReportDefinition | null> {
|
||||
return this.definitionRepository.findOne({
|
||||
where: { code, tenantId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new report definition
|
||||
*/
|
||||
async createDefinition(
|
||||
dto: CreateReportDefinitionDto,
|
||||
tenantId: string,
|
||||
createdBy: string
|
||||
): Promise<ReportDefinition> {
|
||||
const existing = await this.findDefinitionByCode(dto.code, tenantId);
|
||||
if (existing) {
|
||||
throw new ConflictError(`Report definition with code '${dto.code}' already exists`);
|
||||
}
|
||||
|
||||
const definition = this.definitionRepository.create({
|
||||
...dto,
|
||||
tenantId,
|
||||
createdBy,
|
||||
});
|
||||
|
||||
const saved = await this.definitionRepository.save(definition);
|
||||
logger.info('Report definition created', { id: saved.id, code: dto.code, tenantId });
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a report definition
|
||||
*/
|
||||
async updateDefinition(
|
||||
id: string,
|
||||
dto: UpdateReportDefinitionDto,
|
||||
tenantId: string
|
||||
): Promise<ReportDefinition> {
|
||||
const definition = await this.findDefinitionById(id, tenantId);
|
||||
|
||||
Object.assign(definition, dto);
|
||||
|
||||
const updated = await this.definitionRepository.save(definition);
|
||||
logger.info('Report definition updated', { id, tenantId });
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a report definition (soft delete by setting isActive = false)
|
||||
*/
|
||||
async deleteDefinition(id: string, tenantId: string): Promise<void> {
|
||||
const definition = await this.findDefinitionById(id, tenantId);
|
||||
definition.isActive = false;
|
||||
await this.definitionRepository.save(definition);
|
||||
logger.info('Report definition deactivated', { id, tenantId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all unique categories
|
||||
*/
|
||||
async getCategories(tenantId: string): Promise<string[]> {
|
||||
const result = await this.definitionRepository
|
||||
.createQueryBuilder('def')
|
||||
.select('DISTINCT def.category', 'category')
|
||||
.where('def.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('def.category IS NOT NULL')
|
||||
.andWhere('def.is_active = true')
|
||||
.orderBy('def.category', 'ASC')
|
||||
.getRawMany();
|
||||
|
||||
return result.map((r: { category: string }) => r.category);
|
||||
}
|
||||
|
||||
// ==================== REPORT SCHEDULES ====================
|
||||
|
||||
/**
|
||||
* Get all report schedules with optional filtering
|
||||
*/
|
||||
async findAllSchedules(
|
||||
tenantId: string,
|
||||
filters: ReportScheduleFiltersDto = {}
|
||||
): Promise<{ data: ReportSchedule[]; total: number }> {
|
||||
const { reportDefinitionId, isActive, search, page = 1, limit = 20 } = filters;
|
||||
|
||||
const where: FindOptionsWhere<ReportSchedule>[] = [];
|
||||
const baseWhere: FindOptionsWhere<ReportSchedule> = { tenantId };
|
||||
|
||||
if (reportDefinitionId) {
|
||||
baseWhere.reportDefinitionId = reportDefinitionId;
|
||||
}
|
||||
|
||||
if (isActive !== undefined) {
|
||||
baseWhere.isActive = isActive;
|
||||
}
|
||||
|
||||
if (search) {
|
||||
where.push({ ...baseWhere, name: ILike(`%${search}%`) });
|
||||
} else {
|
||||
where.push(baseWhere);
|
||||
}
|
||||
|
||||
const [data, total] = await this.scheduleRepository.findAndCount({
|
||||
where: where.length > 0 ? where : undefined,
|
||||
relations: ['reportDefinition'],
|
||||
order: { name: 'ASC' },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
});
|
||||
|
||||
return { data, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a report schedule by ID
|
||||
*/
|
||||
async findScheduleById(id: string, tenantId: string): Promise<ReportSchedule> {
|
||||
const schedule = await this.scheduleRepository.findOne({
|
||||
where: { id, tenantId },
|
||||
relations: ['reportDefinition', 'recipients'],
|
||||
});
|
||||
|
||||
if (!schedule) {
|
||||
throw new NotFoundError('Report schedule not found');
|
||||
}
|
||||
|
||||
return schedule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new report schedule
|
||||
*/
|
||||
async createSchedule(
|
||||
dto: CreateReportScheduleDto,
|
||||
tenantId: string,
|
||||
createdBy: string
|
||||
): Promise<ReportSchedule> {
|
||||
// Verify definition exists
|
||||
await this.findDefinitionById(dto.reportDefinitionId, tenantId);
|
||||
|
||||
const schedule = this.scheduleRepository.create({
|
||||
...dto,
|
||||
tenantId,
|
||||
createdBy,
|
||||
});
|
||||
|
||||
const saved = await this.scheduleRepository.save(schedule);
|
||||
logger.info('Report schedule created', { id: saved.id, name: dto.name, tenantId });
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a report schedule
|
||||
*/
|
||||
async updateSchedule(
|
||||
id: string,
|
||||
dto: UpdateReportScheduleDto,
|
||||
tenantId: string
|
||||
): Promise<ReportSchedule> {
|
||||
const schedule = await this.findScheduleById(id, tenantId);
|
||||
|
||||
Object.assign(schedule, dto);
|
||||
|
||||
const updated = await this.scheduleRepository.save(schedule);
|
||||
logger.info('Report schedule updated', { id, tenantId });
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle schedule active status
|
||||
*/
|
||||
async toggleSchedule(id: string, tenantId: string, isActive: boolean): Promise<ReportSchedule> {
|
||||
const schedule = await this.findScheduleById(id, tenantId);
|
||||
schedule.isActive = isActive;
|
||||
|
||||
const updated = await this.scheduleRepository.save(schedule);
|
||||
logger.info('Report schedule toggled', { id, isActive, tenantId });
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a report schedule
|
||||
*/
|
||||
async deleteSchedule(id: string, tenantId: string): Promise<void> {
|
||||
const schedule = await this.findScheduleById(id, tenantId);
|
||||
await this.scheduleRepository.remove(schedule);
|
||||
logger.info('Report schedule deleted', { id, tenantId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get schedules due for execution
|
||||
*/
|
||||
async getDueSchedules(): Promise<ReportSchedule[]> {
|
||||
return this.scheduleRepository
|
||||
.createQueryBuilder('schedule')
|
||||
.where('schedule.is_active = true')
|
||||
.andWhere('schedule.next_run_at <= NOW()')
|
||||
.leftJoinAndSelect('schedule.reportDefinition', 'definition')
|
||||
.leftJoinAndSelect('schedule.recipients', 'recipients')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
// ==================== REPORT RECIPIENTS ====================
|
||||
|
||||
/**
|
||||
* Get recipients for a schedule
|
||||
*/
|
||||
async findRecipientsBySchedule(scheduleId: string): Promise<ReportRecipient[]> {
|
||||
return this.recipientRepository.find({
|
||||
where: { scheduleId },
|
||||
order: { createdAt: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a recipient to a schedule
|
||||
*/
|
||||
async addRecipient(
|
||||
dto: CreateReportRecipientDto,
|
||||
tenantId: string
|
||||
): Promise<ReportRecipient> {
|
||||
// Verify schedule exists and belongs to tenant
|
||||
await this.findScheduleById(dto.scheduleId, tenantId);
|
||||
|
||||
if (!dto.userId && !dto.email) {
|
||||
throw new ValidationError('Either userId or email is required');
|
||||
}
|
||||
|
||||
const recipient = this.recipientRepository.create(dto);
|
||||
const saved = await this.recipientRepository.save(recipient);
|
||||
logger.info('Report recipient added', { id: saved.id, scheduleId: dto.scheduleId });
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a recipient
|
||||
*/
|
||||
async updateRecipient(
|
||||
id: string,
|
||||
dto: UpdateReportRecipientDto
|
||||
): Promise<ReportRecipient> {
|
||||
const recipient = await this.recipientRepository.findOne({ where: { id } });
|
||||
|
||||
if (!recipient) {
|
||||
throw new NotFoundError('Report recipient not found');
|
||||
}
|
||||
|
||||
Object.assign(recipient, dto);
|
||||
|
||||
const updated = await this.recipientRepository.save(recipient);
|
||||
logger.info('Report recipient updated', { id });
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a recipient
|
||||
*/
|
||||
async removeRecipient(id: string): Promise<void> {
|
||||
const recipient = await this.recipientRepository.findOne({ where: { id } });
|
||||
|
||||
if (!recipient) {
|
||||
throw new NotFoundError('Report recipient not found');
|
||||
}
|
||||
|
||||
await this.recipientRepository.remove(recipient);
|
||||
logger.info('Report recipient removed', { id });
|
||||
}
|
||||
|
||||
// ==================== CUSTOM REPORTS ====================
|
||||
|
||||
/**
|
||||
* Get all custom reports for a user
|
||||
*/
|
||||
async findAllCustomReports(
|
||||
tenantId: string,
|
||||
ownerId: string,
|
||||
filters: CustomReportFiltersDto = {}
|
||||
): Promise<{ data: CustomReport[]; total: number }> {
|
||||
const { baseDefinitionId, isFavorite, search, page = 1, limit = 20 } = filters;
|
||||
|
||||
const where: FindOptionsWhere<CustomReport>[] = [];
|
||||
const baseWhere: FindOptionsWhere<CustomReport> = { tenantId, ownerId };
|
||||
|
||||
if (baseDefinitionId) {
|
||||
baseWhere.baseDefinitionId = baseDefinitionId;
|
||||
}
|
||||
|
||||
if (isFavorite !== undefined) {
|
||||
baseWhere.isFavorite = isFavorite;
|
||||
}
|
||||
|
||||
if (search) {
|
||||
where.push(
|
||||
{ ...baseWhere, name: ILike(`%${search}%`) },
|
||||
{ ...baseWhere, description: ILike(`%${search}%`) }
|
||||
);
|
||||
} else {
|
||||
where.push(baseWhere);
|
||||
}
|
||||
|
||||
const [data, total] = await this.customReportRepository.findAndCount({
|
||||
where: where.length > 0 ? where : undefined,
|
||||
relations: ['baseDefinition'],
|
||||
order: { isFavorite: 'DESC', name: 'ASC' },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
});
|
||||
|
||||
return { data, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a custom report by ID
|
||||
*/
|
||||
async findCustomReportById(id: string, tenantId: string, ownerId: string): Promise<CustomReport> {
|
||||
const customReport = await this.customReportRepository.findOne({
|
||||
where: { id, tenantId, ownerId },
|
||||
relations: ['baseDefinition'],
|
||||
});
|
||||
|
||||
if (!customReport) {
|
||||
throw new NotFoundError('Custom report not found');
|
||||
}
|
||||
|
||||
return customReport;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a custom report
|
||||
*/
|
||||
async createCustomReport(
|
||||
dto: CreateCustomReportDto,
|
||||
tenantId: string,
|
||||
ownerId: string
|
||||
): Promise<CustomReport> {
|
||||
if (dto.baseDefinitionId) {
|
||||
await this.findDefinitionById(dto.baseDefinitionId, tenantId);
|
||||
}
|
||||
|
||||
const customReport = this.customReportRepository.create({
|
||||
...dto,
|
||||
tenantId,
|
||||
ownerId,
|
||||
});
|
||||
|
||||
const saved = await this.customReportRepository.save(customReport);
|
||||
logger.info('Custom report created', { id: saved.id, name: dto.name, tenantId, ownerId });
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a custom report
|
||||
*/
|
||||
async updateCustomReport(
|
||||
id: string,
|
||||
dto: UpdateCustomReportDto,
|
||||
tenantId: string,
|
||||
ownerId: string
|
||||
): Promise<CustomReport> {
|
||||
const customReport = await this.findCustomReportById(id, tenantId, ownerId);
|
||||
|
||||
if (dto.baseDefinitionId) {
|
||||
await this.findDefinitionById(dto.baseDefinitionId, tenantId);
|
||||
}
|
||||
|
||||
Object.assign(customReport, dto);
|
||||
|
||||
const updated = await this.customReportRepository.save(customReport);
|
||||
logger.info('Custom report updated', { id, tenantId, ownerId });
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle favorite status
|
||||
*/
|
||||
async toggleFavorite(id: string, tenantId: string, ownerId: string): Promise<CustomReport> {
|
||||
const customReport = await this.findCustomReportById(id, tenantId, ownerId);
|
||||
customReport.isFavorite = !customReport.isFavorite;
|
||||
|
||||
const updated = await this.customReportRepository.save(customReport);
|
||||
logger.info('Custom report favorite toggled', { id, isFavorite: customReport.isFavorite });
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a custom report
|
||||
*/
|
||||
async deleteCustomReport(id: string, tenantId: string, ownerId: string): Promise<void> {
|
||||
const customReport = await this.findCustomReportById(id, tenantId, ownerId);
|
||||
await this.customReportRepository.remove(customReport);
|
||||
logger.info('Custom report deleted', { id, tenantId, ownerId });
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user