[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';
|
// Re-export new controllers
|
||||||
import { ReportsService } from '../services';
|
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;
|
public router: Router;
|
||||||
|
|
||||||
constructor(private readonly reportsService: ReportsService) {
|
constructor(private readonly reportsService: LegacyReportsService) {
|
||||||
this.router = Router();
|
this.router = Router();
|
||||||
|
|
||||||
// Sales Reports
|
// 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 { ReportsModule, ReportsModuleOptions } from './reports.module';
|
||||||
export { ReportsService } from './services';
|
export { createReportsRoutes } from './reports.routes';
|
||||||
export { ReportsController } from './controllers';
|
|
||||||
|
// 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 { Router } from 'express';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { ReportsService } from './services';
|
import {
|
||||||
import { ReportsController } from './controllers';
|
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 {
|
export interface ReportsModuleOptions {
|
||||||
dataSource: DataSource;
|
dataSource: DataSource;
|
||||||
basePath?: string;
|
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 {
|
export class ReportsModule {
|
||||||
public router: Router;
|
public router: Router;
|
||||||
|
|
||||||
|
// Services
|
||||||
public reportsService: ReportsService;
|
public reportsService: ReportsService;
|
||||||
|
public executionService: ReportExecutionService;
|
||||||
|
public schedulerService: ReportSchedulerService;
|
||||||
|
public dashboardsService: DashboardsService;
|
||||||
|
public legacyService: LegacyReportsService;
|
||||||
|
|
||||||
private dataSource: DataSource;
|
private dataSource: DataSource;
|
||||||
private basePath: string;
|
private basePath: string;
|
||||||
|
|
||||||
@ -22,17 +74,88 @@ export class ReportsModule {
|
|||||||
this.initializeRoutes();
|
this.initializeRoutes();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize all services with their repositories
|
||||||
|
*/
|
||||||
private initializeServices(): void {
|
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 {
|
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}/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[] {
|
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 { Router } from 'express';
|
||||||
import { reportsController } from './reports.controller.js';
|
import { DataSource } from 'typeorm';
|
||||||
import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js';
|
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
|
// Initialize repositories
|
||||||
router.use(authenticate);
|
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);
|
||||||
|
|
||||||
// ============================================================================
|
// Initialize services
|
||||||
// QUICK REPORTS (direct access without execution record)
|
const reportsService = new ReportsService(
|
||||||
// ============================================================================
|
definitionRepository,
|
||||||
|
scheduleRepository,
|
||||||
|
recipientRepository,
|
||||||
|
customReportRepository
|
||||||
|
);
|
||||||
|
|
||||||
router.get('/quick/trial-balance',
|
const executionService = new ReportExecutionService(
|
||||||
requireRoles('admin', 'manager', 'accountant', 'super_admin'),
|
executionRepository,
|
||||||
(req, res, next) => reportsController.getTrialBalance(req, res, next)
|
definitionRepository,
|
||||||
);
|
dataSource
|
||||||
|
);
|
||||||
|
|
||||||
router.get('/quick/general-ledger',
|
const schedulerService = new ReportSchedulerService(
|
||||||
requireRoles('admin', 'manager', 'accountant', 'super_admin'),
|
scheduleRepository,
|
||||||
(req, res, next) => reportsController.getGeneralLedger(req, res, next)
|
scheduleExecutionRepository,
|
||||||
);
|
executionService
|
||||||
|
);
|
||||||
|
|
||||||
// ============================================================================
|
const dashboardsService = new DashboardsService(
|
||||||
// DEFINITIONS
|
dashboardRepository,
|
||||||
// ============================================================================
|
widgetRepository,
|
||||||
|
queryRepository,
|
||||||
|
dataSource
|
||||||
|
);
|
||||||
|
|
||||||
// List all report definitions
|
const legacyService = new LegacyReportsService(dataSource);
|
||||||
router.get('/definitions',
|
|
||||||
requireRoles('admin', 'manager', 'accountant', 'super_admin'),
|
|
||||||
(req, res, next) => reportsController.findAllDefinitions(req, res, next)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get specific definition
|
// Initialize controllers
|
||||||
router.get('/definitions/:id',
|
const reportsController = new ReportsController(
|
||||||
requireRoles('admin', 'manager', 'accountant', 'super_admin'),
|
reportsService,
|
||||||
(req, res, next) => reportsController.findDefinitionById(req, res, next)
|
executionService,
|
||||||
);
|
schedulerService
|
||||||
|
);
|
||||||
|
|
||||||
// Create custom definition (admin only)
|
const dashboardsController = new DashboardsController(dashboardsService);
|
||||||
router.post('/definitions',
|
const legacyController = new LegacyReportsController(legacyService);
|
||||||
requireRoles('admin', 'super_admin'),
|
|
||||||
(req, res, next) => reportsController.createDefinition(req, res, next)
|
|
||||||
);
|
|
||||||
|
|
||||||
// ============================================================================
|
// Mount routes
|
||||||
// EXECUTIONS
|
router.use('/', reportsController.router);
|
||||||
// ============================================================================
|
router.use('/dashboards', dashboardsController.router);
|
||||||
|
router.use('/legacy', legacyController.router);
|
||||||
|
|
||||||
// Execute a report
|
return router;
|
||||||
router.post('/execute',
|
}
|
||||||
requireRoles('admin', 'manager', 'accountant', 'super_admin'),
|
|
||||||
(req, res, next) => reportsController.executeReport(req, res, next)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get recent executions
|
export default createReportsRoutes;
|
||||||
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;
|
|
||||||
|
|||||||
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';
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
export interface ReportDateRange {
|
export interface ReportDateRange {
|
||||||
@ -50,7 +57,7 @@ export interface FinancialSummary {
|
|||||||
margin: number;
|
margin: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ReportsService {
|
export class LegacyReportsService {
|
||||||
constructor(private readonly dataSource: DataSource) {}
|
constructor(private readonly dataSource: DataSource) {}
|
||||||
|
|
||||||
// ==================== Sales Reports ====================
|
// ==================== 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