[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:
Adrian Flores Cortes 2026-01-26 18:51:51 -06:00
parent 5ee2023428
commit 6c6ce41343
30 changed files with 4277 additions and 96 deletions

View 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);
}
}
}

View File

@ -1,10 +1,15 @@
import { Request, Response, NextFunction, Router } from 'express';
import { ReportsService } from '../services';
// Re-export new controllers
export { ReportsController } from './reports.controller';
export { DashboardsController } from './dashboards.controller';
export class ReportsController {
// Legacy controller (kept for backwards compatibility)
import { Request, Response, NextFunction, Router } from 'express';
import { LegacyReportsService } from '../services';
export class LegacyReportsController {
public router: Router;
constructor(private readonly reportsService: ReportsService) {
constructor(private readonly reportsService: LegacyReportsService) {
this.router = Router();
// Sales Reports

View 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);
}
}
}

View 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;
}>;
}

View 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;
}

View 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;
}

View 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';

View 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
}

View 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;
}

View 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;
}

View 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[];
}

View 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[];
}

View 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[];
}

View 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;
}

View File

@ -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;
}

View 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';

View 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[];
}

View 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[];
}

View 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;
}

View 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[];
}

View 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;
}

View 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;
}

View File

@ -1,3 +1,15 @@
// Module exports
export { ReportsModule, ReportsModuleOptions } from './reports.module';
export { ReportsService } from './services';
export { ReportsController } from './controllers';
export { createReportsRoutes } from './reports.routes';
// Entity exports
export * from './entities';
// Service exports
export * from './services';
// Controller exports
export * from './controllers';
// DTO exports
export * from './dto';

View File

@ -1,16 +1,68 @@
import { Router } from 'express';
import { DataSource } from 'typeorm';
import { ReportsService } from './services';
import { ReportsController } from './controllers';
import {
ReportsService,
ReportExecutionService,
ReportSchedulerService,
DashboardsService,
LegacyReportsService,
} from './services';
import { ReportsController, DashboardsController, LegacyReportsController } from './controllers';
import {
ReportDefinition,
ReportExecution,
ReportSchedule,
ReportRecipient,
ScheduleExecution,
Dashboard,
DashboardWidget,
WidgetQuery,
CustomReport,
DataModelEntity,
DataModelField,
DataModelRelationship,
} from './entities';
/**
* Options for configuring the Reports module
*/
export interface ReportsModuleOptions {
dataSource: DataSource;
basePath?: string;
}
/**
* ReportsModule
*
* Complete reports and dashboards management module.
*
* Features:
* - Report definitions (templates)
* - Report executions (history and results)
* - Scheduled reports with delivery
* - Custom user reports
* - Dashboards with widgets
* - Legacy analytics reports
*
* Routes:
* - /api/v1/reports/definitions/* - Report definitions
* - /api/v1/reports/execute - Execute reports
* - /api/v1/reports/executions/* - Execution history
* - /api/v1/reports/schedules/* - Scheduled reports
* - /api/v1/reports/custom/* - Custom reports
* - /api/v1/dashboards/* - Dashboards and widgets
* - /api/v1/reports/legacy/* - Legacy analytics
*/
export class ReportsModule {
public router: Router;
// Services
public reportsService: ReportsService;
public executionService: ReportExecutionService;
public schedulerService: ReportSchedulerService;
public dashboardsService: DashboardsService;
public legacyService: LegacyReportsService;
private dataSource: DataSource;
private basePath: string;
@ -22,17 +74,88 @@ export class ReportsModule {
this.initializeRoutes();
}
/**
* Initialize all services with their repositories
*/
private initializeServices(): void {
this.reportsService = new ReportsService(this.dataSource);
// Get repositories
const definitionRepository = this.dataSource.getRepository(ReportDefinition);
const executionRepository = this.dataSource.getRepository(ReportExecution);
const scheduleRepository = this.dataSource.getRepository(ReportSchedule);
const recipientRepository = this.dataSource.getRepository(ReportRecipient);
const scheduleExecutionRepository = this.dataSource.getRepository(ScheduleExecution);
const customReportRepository = this.dataSource.getRepository(CustomReport);
const dashboardRepository = this.dataSource.getRepository(Dashboard);
const widgetRepository = this.dataSource.getRepository(DashboardWidget);
const queryRepository = this.dataSource.getRepository(WidgetQuery);
// Create services
this.reportsService = new ReportsService(
definitionRepository,
scheduleRepository,
recipientRepository,
customReportRepository
);
this.executionService = new ReportExecutionService(
executionRepository,
definitionRepository,
this.dataSource
);
this.schedulerService = new ReportSchedulerService(
scheduleRepository,
scheduleExecutionRepository,
this.executionService
);
this.dashboardsService = new DashboardsService(
dashboardRepository,
widgetRepository,
queryRepository,
this.dataSource
);
// Legacy service for backwards compatibility
this.legacyService = new LegacyReportsService(this.dataSource);
}
/**
* Initialize routes with controllers
*/
private initializeRoutes(): void {
const reportsController = new ReportsController(this.reportsService);
const reportsController = new ReportsController(
this.reportsService,
this.executionService,
this.schedulerService
);
const dashboardsController = new DashboardsController(this.dashboardsService);
const legacyController = new LegacyReportsController(this.legacyService);
// Mount routes
this.router.use(`${this.basePath}/reports`, reportsController.router);
this.router.use(`${this.basePath}/dashboards`, dashboardsController.router);
this.router.use(`${this.basePath}/reports/legacy`, legacyController.router);
}
// Reports module doesn't have its own entities - it uses data from other modules
/**
* Get all entities managed by this module
*/
static getEntities(): Function[] {
return [];
return [
ReportDefinition,
ReportExecution,
ReportSchedule,
ReportRecipient,
ScheduleExecution,
Dashboard,
DashboardWidget,
WidgetQuery,
CustomReport,
DataModelEntity,
DataModelField,
DataModelRelationship,
];
}
}

View File

@ -1,96 +1,99 @@
import { Router } from 'express';
import { reportsController } from './reports.controller.js';
import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js';
import { DataSource } from 'typeorm';
import {
ReportDefinition,
ReportExecution,
ReportSchedule,
ReportRecipient,
ScheduleExecution,
Dashboard,
DashboardWidget,
WidgetQuery,
CustomReport,
} from './entities';
import {
ReportsService,
ReportExecutionService,
ReportSchedulerService,
DashboardsService,
LegacyReportsService,
} from './services';
import { ReportsController, DashboardsController, LegacyReportsController } from './controllers';
const router = Router();
/**
* Creates and configures the reports routes
*
* Routes:
* - /api/v1/reports/definitions/* - Report definitions CRUD
* - /api/v1/reports/execute - Execute a report
* - /api/v1/reports/executions/* - Execution history
* - /api/v1/reports/schedules/* - Scheduled reports
* - /api/v1/reports/custom/* - Custom user reports
* - /api/v1/dashboards/* - Dashboards and widgets
* - /api/v1/reports/legacy/* - Legacy analytics (sales, inventory, financial)
*
* @param dataSource - TypeORM DataSource instance
* @returns Configured Express Router
*/
export function createReportsRoutes(dataSource: DataSource): Router {
const router = Router();
// All routes require authentication
router.use(authenticate);
// Initialize repositories
const definitionRepository = dataSource.getRepository(ReportDefinition);
const executionRepository = dataSource.getRepository(ReportExecution);
const scheduleRepository = dataSource.getRepository(ReportSchedule);
const recipientRepository = dataSource.getRepository(ReportRecipient);
const scheduleExecutionRepository = dataSource.getRepository(ScheduleExecution);
const customReportRepository = dataSource.getRepository(CustomReport);
const dashboardRepository = dataSource.getRepository(Dashboard);
const widgetRepository = dataSource.getRepository(DashboardWidget);
const queryRepository = dataSource.getRepository(WidgetQuery);
// ============================================================================
// QUICK REPORTS (direct access without execution record)
// ============================================================================
// Initialize services
const reportsService = new ReportsService(
definitionRepository,
scheduleRepository,
recipientRepository,
customReportRepository
);
router.get('/quick/trial-balance',
requireRoles('admin', 'manager', 'accountant', 'super_admin'),
(req, res, next) => reportsController.getTrialBalance(req, res, next)
);
const executionService = new ReportExecutionService(
executionRepository,
definitionRepository,
dataSource
);
router.get('/quick/general-ledger',
requireRoles('admin', 'manager', 'accountant', 'super_admin'),
(req, res, next) => reportsController.getGeneralLedger(req, res, next)
);
const schedulerService = new ReportSchedulerService(
scheduleRepository,
scheduleExecutionRepository,
executionService
);
// ============================================================================
// DEFINITIONS
// ============================================================================
const dashboardsService = new DashboardsService(
dashboardRepository,
widgetRepository,
queryRepository,
dataSource
);
// List all report definitions
router.get('/definitions',
requireRoles('admin', 'manager', 'accountant', 'super_admin'),
(req, res, next) => reportsController.findAllDefinitions(req, res, next)
);
const legacyService = new LegacyReportsService(dataSource);
// Get specific definition
router.get('/definitions/:id',
requireRoles('admin', 'manager', 'accountant', 'super_admin'),
(req, res, next) => reportsController.findDefinitionById(req, res, next)
);
// Initialize controllers
const reportsController = new ReportsController(
reportsService,
executionService,
schedulerService
);
// Create custom definition (admin only)
router.post('/definitions',
requireRoles('admin', 'super_admin'),
(req, res, next) => reportsController.createDefinition(req, res, next)
);
const dashboardsController = new DashboardsController(dashboardsService);
const legacyController = new LegacyReportsController(legacyService);
// ============================================================================
// EXECUTIONS
// ============================================================================
// Mount routes
router.use('/', reportsController.router);
router.use('/dashboards', dashboardsController.router);
router.use('/legacy', legacyController.router);
// Execute a report
router.post('/execute',
requireRoles('admin', 'manager', 'accountant', 'super_admin'),
(req, res, next) => reportsController.executeReport(req, res, next)
);
return router;
}
// Get recent executions
router.get('/executions',
requireRoles('admin', 'manager', 'accountant', 'super_admin'),
(req, res, next) => reportsController.findRecentExecutions(req, res, next)
);
// Get specific execution
router.get('/executions/:id',
requireRoles('admin', 'manager', 'accountant', 'super_admin'),
(req, res, next) => reportsController.findExecutionById(req, res, next)
);
// ============================================================================
// SCHEDULES
// ============================================================================
// List schedules
router.get('/schedules',
requireRoles('admin', 'manager', 'super_admin'),
(req, res, next) => reportsController.findAllSchedules(req, res, next)
);
// Create schedule
router.post('/schedules',
requireRoles('admin', 'super_admin'),
(req, res, next) => reportsController.createSchedule(req, res, next)
);
// Toggle schedule
router.patch('/schedules/:id/toggle',
requireRoles('admin', 'super_admin'),
(req, res, next) => reportsController.toggleSchedule(req, res, next)
);
// Delete schedule
router.delete('/schedules/:id',
requireRoles('admin', 'super_admin'),
(req, res, next) => reportsController.deleteSchedule(req, res, next)
);
export default router;
export default createReportsRoutes;

View 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;
}
}

View File

@ -1,3 +1,10 @@
// Re-export new services
export { ReportsService } from './reports.service';
export { ReportExecutionService } from './report-execution.service';
export { ReportSchedulerService } from './report-scheduler.service';
export { DashboardsService } from './dashboards.service';
// Legacy types and service (kept for backwards compatibility)
import { DataSource } from 'typeorm';
export interface ReportDateRange {
@ -50,7 +57,7 @@ export interface FinancialSummary {
margin: number;
}
export class ReportsService {
export class LegacyReportsService {
constructor(private readonly dataSource: DataSource) {}
// ==================== Sales Reports ====================

View 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;
}
}

View 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);
}
}

View 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 });
}
}