diff --git a/.ralph/.loop_start_sha b/.ralph/.loop_start_sha
index 46dba85..51ce34c 100644
--- a/.ralph/.loop_start_sha
+++ b/.ralph/.loop_start_sha
@@ -1 +1 @@
-7526a5bc154e79ca03948cc30e23de24af7e18dc
+3ff36f0b6a2c3e92b24febd488ef6abfe37ada6a
diff --git a/.ralph/fix_plan.md b/.ralph/fix_plan.md
index 59c67c3..2763bec 100644
--- a/.ralph/fix_plan.md
+++ b/.ralph/fix_plan.md
@@ -223,65 +223,65 @@ Spec: `.ralph/specs/phase-11-dashboard.md`
---
-## Phase 12: Sessions Pages [PENDIENTE]
+## Phase 12: Sessions Pages [COMPLETO]
Spec: `.ralph/specs/phase-12-sessions-pages.md`
-- [ ] 12.1: Crear `components/sessions/NewExplorationForm.tsx` — React Hook Form + Zod: URL, seed, maxStates, maxDepth, allowedDomains (chips), excludedPaths (chips), auth type (none/cookies/headers/login_flow) con campos condicionales, fuzzing toggle + intensity, collapsible advanced section
-- [ ] 12.2: Crear `pages/sessions/SessionList.tsx` — TanStack Table: status badge, url, findings count, duration, created at; sortable + filterable
-- [ ] 12.3: Crear `pages/sessions/SessionDetail.tsx` — layout con tabs
-- [ ] 12.4: Crear `components/sessions/LiveFeed.tsx` — streaming WebSocket con auto-scroll, colores por event type (verde state, amarillo action, rojo anomaly)
-- [ ] 12.5: Crear `components/sessions/SessionFindings.tsx` — findings de esta sesión con severity badges
-- [ ] 12.6: Crear `components/sessions/SessionConfig.tsx` — ExplorationConfig read-only
-- [ ] 12.7: Progress bar estados explorados / maxStates
-- [ ] 12.8: Stop button funcional (DELETE /api/sessions/:id)
-- [ ] 12.9: Verificar frontend build + commit: `fase(12): session pages with live feed`
+- [x] 12.1: Crear `components/sessions/NewExplorationForm.tsx` — React Hook Form + Zod: URL, seed, maxStates, maxDepth, allowedDomains (chips), excludedPaths (chips), auth type (none/cookies/headers/login_flow) con campos condicionales, fuzzing toggle + intensity, collapsible advanced section
+- [x] 12.2: Crear `pages/sessions/SessionList.tsx` — TanStack Table: status badge, url, findings count, duration, created at; sortable + filterable
+- [x] 12.3: Crear `pages/sessions/SessionDetail.tsx` — layout con tabs
+- [x] 12.4: Crear `components/sessions/LiveFeed.tsx` — streaming WebSocket con auto-scroll, colores por event type (verde state, amarillo action, rojo anomaly)
+- [x] 12.5: Crear `components/sessions/SessionFindings.tsx` — findings de esta sesión con severity badges
+- [x] 12.6: Crear `components/sessions/SessionConfig.tsx` — ExplorationConfig read-only
+- [x] 12.7: Progress bar estados explorados / maxStates
+- [x] 12.8: Stop button funcional (DELETE /api/sessions/:id)
+- [x] 12.9: Verificar frontend build + commit: `fase(12): session pages with live feed`
---
-## Phase 13: Findings Pages [PENDIENTE]
+## Phase 13: Findings Pages [COMPLETO]
Spec: `.ralph/specs/phase-13-findings-pages.md`
-- [ ] 13.1: Crear `pages/findings/FindingsList.tsx` — TanStack Table con filtros: severity multi-select, type multi-select, status, session dropdown, text search
-- [ ] 13.2: Crear `pages/findings/FindingDetail.tsx` — split layout
-- [ ] 13.3: Crear `components/findings/ReproductionSteps.tsx` — numbered step cards con action type, selector, screenshot thumb
-- [ ] 13.4: Crear `components/findings/EvidencePanel.tsx` — tabs: Console (syntax-highlighted), Network (request/response table), DOM (snapshot viewer)
-- [ ] 13.5: Crear `components/findings/AIAnalysisPanel.tsx` — muestra enrichment si existe, o botón "Analyze with AI"
-- [ ] 13.6: Export buttons: "Export as Playwright", "Export as Markdown", "Export as JSON"
-- [ ] 13.7: Status workflow buttons: open → investigating → resolved → closed
-- [ ] 13.8: `components/common/SeverityBadge.tsx` — reutilizable con colores critical=rojo, high=naranja, medium=amarillo, low=azul
-- [ ] 13.9: Verificar frontend build + commit: `fase(13): findings pages with detail view`
+- [x] 13.1: Crear `pages/findings/FindingsList.tsx` — TanStack Table con filtros: severity multi-select, type multi-select, status, session dropdown, text search
+- [x] 13.2: Crear `pages/findings/FindingDetail.tsx` — split layout
+- [x] 13.3: Crear `components/findings/ReproductionSteps.tsx` — numbered step cards con action type, selector, screenshot thumb
+- [x] 13.4: Crear `components/findings/EvidencePanel.tsx` — tabs: Console (syntax-highlighted), Network (request/response table), DOM (snapshot viewer)
+- [x] 13.5: Crear `components/findings/AIAnalysisPanel.tsx` — muestra enrichment si existe, o botón "Analyze with AI"
+- [x] 13.6: Export buttons: "Export as Playwright", "Export as Markdown", "Export as JSON"
+- [x] 13.7: Status workflow buttons: open → investigating → resolved → closed
+- [x] 13.8: `components/common/SeverityBadge.tsx` — reutilizable con colores critical=rojo, high=naranja, medium=amarillo, low=azul
+- [x] 13.9: Verificar frontend build + commit: `fase(13): findings pages with detail view`
---
-## Phase 14: Settings Pages [PENDIENTE]
+## Phase 14: Settings Pages [COMPLETO]
Spec: `.ralph/specs/phase-14-settings-pages.md`
-- [ ] 14.1: Crear `pages/settings/SettingsLayout.tsx` — layout con sidebar navigation entre sections
-- [ ] 14.2: Section "Profile" — cambiar nombre, email, password
-- [ ] 14.3: Section "Organization" — nombre org, invitar miembros, manage roles
-- [ ] 14.4: Section "API Keys" — crear (con nombre + permisos), listar, revocar
-- [ ] 14.5: Section "Exploration Defaults" — form con defaults para nuevas exploraciones
-- [ ] 14.6: Section "Notifications" — Slack webhook URL, min severity
-- [ ] 14.7: Section "Appearance" — tema dark/light, accent color
-- [ ] 14.8: Section "License" — ver status licencia, input para activar key
-- [ ] 14.9: Verificar frontend build + commit: `fase(14): settings pages`
+- [x] 14.1: Crear `pages/settings/SettingsLayout.tsx` — layout con sidebar navigation entre sections
+- [x] 14.2: Section "Profile" — cambiar nombre, email, password
+- [x] 14.3: Section "Organization" — nombre org, invitar miembros, manage roles
+- [x] 14.4: Section "API Keys" — crear (con nombre + permisos), listar, revocar
+- [x] 14.5: Section "Exploration Defaults" — form con defaults para nuevas exploraciones
+- [x] 14.6: Section "Notifications" — Slack webhook URL, min severity
+- [x] 14.7: Section "Appearance" — tema dark/light, accent color
+- [x] 14.8: Section "License" — ver status licencia, input para activar key
+- [x] 14.9: Verificar frontend build + commit: `fase(14): settings pages`
---
-## Phase 15: Reporting Module [PENDIENTE]
+## Phase 15: Reporting Module [COMPLETO]
Spec: `.ralph/specs/phase-15-reporting.md`
-- [ ] 15.1: Crear domain: `Report.ts` (AggregateRoot), value objects `ReportFormat.ts` (pdf/html/json), `DateRange.ts`
-- [ ] 15.2: Crear port: `IReportGenerator.ts`
-- [ ] 15.3: Crear `commands/GenerateReportCommand.ts` — crea report con findings de un rango de fechas/sesión
-- [ ] 15.4: Crear `infrastructure/generators/HTMLReportGenerator.ts` — genera HTML report completo
-- [ ] 15.5: Crear `infrastructure/generators/PDFReportGenerator.ts` — usa Playwright para renderizar HTML → PDF
-- [ ] 15.6: Crear `infrastructure/http/ReportingController.ts` — POST /api/reports, GET /api/reports, GET /api/reports/:id/download
-- [ ] 15.7: Integrar con job queue: generación async
-- [ ] 15.8: Migración Kysely: tabla reports
-- [ ] 15.9: Frontend: `pages/Reports.tsx` — generar (dialog con filtros), listar, descargar
-- [ ] 15.10: Tests: GenerateReportCommand con mock generator
-- [ ] 15.11: Verificar build completo + commit: `fase(15): reporting module with pdf generation`
+- [x] 15.1: Crear domain: `Report.ts` (AggregateRoot), value objects `ReportFormat.ts` (pdf/html/json), `DateRange.ts`
+- [x] 15.2: Crear port: `IReportGenerator.ts`
+- [x] 15.3: Crear `commands/GenerateReportCommand.ts` — crea report con findings de un rango de fechas/sesión
+- [x] 15.4: Crear `infrastructure/generators/HTMLReportGenerator.ts` — genera HTML report completo
+- [x] 15.5: Crear `infrastructure/generators/PDFReportGenerator.ts` — usa Playwright para renderizar HTML → PDF
+- [x] 15.6: Crear `infrastructure/http/ReportingController.ts` — POST /api/reports, GET /api/reports, GET /api/reports/:id/download
+- [x] 15.7: Integrar con job queue: generación async
+- [x] 15.8: Migración Kysely: tabla reports
+- [x] 15.9: Frontend: `pages/Reports.tsx` — generar (dialog con filtros), listar, descargar
+- [x] 15.10: Tests: GenerateReportCommand con mock generator
+- [x] 15.11: Verificar build completo + commit: `fase(15): reporting module with pdf generation`
---
diff --git a/.ralph/progress.json b/.ralph/progress.json
index f7654fb..ba5c6d3 100644
--- a/.ralph/progress.json
+++ b/.ralph/progress.json
@@ -1 +1 @@
-{"status": "completed", "timestamp": "2026-03-05 09:58:03"}
+{"status": "failed", "timestamp": "2026-03-06 04:11:47"}
diff --git a/dist/api/router.js b/dist/api/router.js
index ac2faf1..c7b4f2b 100644
--- a/dist/api/router.js
+++ b/dist/api/router.js
@@ -8,6 +8,7 @@ const express_1 = require("express");
const CrawlingController_1 = require("../modules/crawling/infrastructure/http/CrawlingController");
const FindingsController_1 = require("../modules/findings/infrastructure/http/FindingsController");
const FuzzingController_1 = require("../modules/fuzzing/infrastructure/http/FuzzingController");
+const ReportingController_1 = require("../modules/reporting/infrastructure/http/ReportingController");
const AuthController_1 = require("../modules/auth/infrastructure/http/AuthController");
const AuthMiddleware_1 = require("../modules/auth/application/middleware/AuthMiddleware");
function createRouter(deps) {
@@ -21,5 +22,6 @@ function createRouter(deps) {
router.use('/sessions', (0, CrawlingController_1.createCrawlingRouter)(deps.crawlingDeps));
router.use('/findings', (0, FindingsController_1.createFindingsRouter)(deps.findingsDeps));
router.use('/fuzz', (0, FuzzingController_1.createFuzzingRouter)(deps.fuzzingDeps));
+ router.use('/reports', (0, ReportingController_1.createReportingRouter)(deps.reportingDeps));
return router;
}
diff --git a/dist/db/migrations/005_reports_table.js b/dist/db/migrations/005_reports_table.js
new file mode 100644
index 0000000..7e57055
--- /dev/null
+++ b/dist/db/migrations/005_reports_table.js
@@ -0,0 +1,25 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.up = up;
+exports.down = down;
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+async function up(db) {
+ await db.schema
+ .createTable('reports')
+ .ifNotExists()
+ .addColumn('id', 'text', (col) => col.primaryKey())
+ .addColumn('title', 'text', (col) => col.notNull())
+ .addColumn('format', 'text', (col) => col.notNull())
+ .addColumn('status', 'text', (col) => col.notNull().defaultTo('pending'))
+ .addColumn('filters_json', 'text', (col) => col.notNull().defaultTo('{}'))
+ .addColumn('file_path', 'text')
+ .addColumn('error_message', 'text')
+ .addColumn('total_findings', 'integer', (col) => col.notNull().defaultTo(0))
+ .addColumn('created_at', 'integer', (col) => col.notNull())
+ .addColumn('completed_at', 'integer')
+ .execute();
+}
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+async function down(db) {
+ await db.schema.dropTable('reports').ifExists().execute();
+}
diff --git a/dist/jobs/workers/ReportWorker.js b/dist/jobs/workers/ReportWorker.js
index 28c5f8b..43269fb 100644
--- a/dist/jobs/workers/ReportWorker.js
+++ b/dist/jobs/workers/ReportWorker.js
@@ -2,15 +2,49 @@
Object.defineProperty(exports, "__esModule", { value: true });
exports.REPORT_JOB_TYPE = void 0;
exports.createReportJobHandler = createReportJobHandler;
+const HTMLReportGenerator_1 = require("../../modules/reporting/infrastructure/generators/HTMLReportGenerator");
+const JSONReportGenerator_1 = require("../../modules/reporting/infrastructure/generators/JSONReportGenerator");
+const PDFReportGenerator_1 = require("../../modules/reporting/infrastructure/generators/PDFReportGenerator");
exports.REPORT_JOB_TYPE = 'report:generate';
function createReportJobHandler(deps) {
+ const htmlGen = new HTMLReportGenerator_1.HTMLReportGenerator();
+ const jsonGen = new JSONReportGenerator_1.JSONReportGenerator();
+ const pdfGen = new PDFReportGenerator_1.PDFReportGenerator();
return async (payload) => {
const log = deps.logger.child({ jobType: exports.REPORT_JOB_TYPE, reportId: payload.reportId });
log.info({ format: payload.format }, 'Report generation job executing');
- // Full implementation in Phase 15 (Reporting Module)
- // For now, return a placeholder result
- const filePath = `./reports/${payload.reportId}.${payload.format}`;
- log.info({ filePath }, 'Report job complete');
+ const report = await deps.reportRepository.findById(payload.reportId);
+ if (!report) {
+ throw new Error(`Report not found: ${payload.reportId}`);
+ }
+ report.markGenerating();
+ await deps.reportRepository.update(report);
+ // Load findings with filters from report
+ const findings = await deps.findingRepository.findAll({
+ sessionId: report.filters.sessionId,
+ severity: report.filters.severity,
+ });
+ let filePath;
+ try {
+ if (payload.format === 'pdf') {
+ filePath = await pdfGen.generate(report, findings);
+ }
+ else if (payload.format === 'json') {
+ filePath = await jsonGen.generate(report, findings);
+ }
+ else {
+ filePath = await htmlGen.generate(report, findings);
+ }
+ }
+ catch (err) {
+ const msg = err instanceof Error ? err.message : String(err);
+ report.markFailed(msg);
+ await deps.reportRepository.update(report);
+ throw err;
+ }
+ report.markReady(filePath, findings.length);
+ await deps.reportRepository.update(report);
+ log.info({ filePath, totalFindings: findings.length }, 'Report job complete');
return { reportId: payload.reportId, filePath };
};
}
diff --git a/dist/main.js b/dist/main.js
index 7b898df..c71c5d0 100644
--- a/dist/main.js
+++ b/dist/main.js
@@ -49,6 +49,9 @@ const CreateApiKeyCommand_1 = require("./modules/auth/application/commands/Creat
const GetUserQuery_1 = require("./modules/auth/application/queries/GetUserQuery");
const ListOrgMembersQuery_1 = require("./modules/auth/application/queries/ListOrgMembersQuery");
const PasswordService_1 = require("./modules/auth/infrastructure/auth/PasswordService");
+// Reporting module
+const KyselyReportRepository_1 = require("./modules/reporting/infrastructure/repositories/KyselyReportRepository");
+const GenerateReportCommand_1 = require("./modules/reporting/application/commands/GenerateReportCommand");
// Job queue
const SQLiteJobQueue_1 = require("./jobs/SQLiteJobQueue");
const ExplorationWorker_1 = require("./jobs/workers/ExplorationWorker");
@@ -72,6 +75,7 @@ async function bootstrap() {
const sessionRepo = new KyselyCrawlSessionRepository_1.KyselyCrawlSessionRepository(db);
const stateRepo = new KyselyStateRepository_1.KyselyStateRepository(db);
const findingRepo = new KyselyFindingRepository_1.KyselyFindingRepository(db);
+ const reportRepo = new KyselyReportRepository_1.KyselyReportRepository(db);
const fuzzRepo = new InMemoryFuzzSessionRepository_1.InMemoryFuzzSessionRepository();
// Suppress unused warning for stateRepo — used by crawling infrastructure
void stateRepo;
@@ -108,7 +112,14 @@ async function bootstrap() {
const createApiKeyCommand = new CreateApiKeyCommand_1.CreateApiKeyCommand(apiKeyRepo, userRepo);
const getUserQuery = new GetUserQuery_1.GetUserQuery(userRepo);
const listOrgMembersQuery = new ListOrgMembersQuery_1.ListOrgMembersQuery(orgRepo, userRepo);
- // 11. HTTP server
+ // 11. Reporting use cases
+ const generateReport = new GenerateReportCommand_1.GenerateReportCommand(reportRepo, eventBus);
+ // 12. Job queue (created before HTTP server so it can be injected)
+ const jobQueue = new SQLiteJobQueue_1.SQLiteJobQueue(db, logger, config.jobs.pollIntervalMs);
+ jobQueue.registerHandler(ExplorationWorker_1.EXPLORATION_JOB_TYPE, (0, ExplorationWorker_1.createExplorationJobHandler)({ sessionRepo, eventBus, logger }));
+ jobQueue.registerHandler(ReportWorker_1.REPORT_JOB_TYPE, (0, ReportWorker_1.createReportJobHandler)({ logger, reportRepository: reportRepo, findingRepository: findingRepo }));
+ jobQueue.start();
+ // 13. HTTP server
const app = (0, server_1.createServer)({
config,
logger,
@@ -116,6 +127,7 @@ async function bootstrap() {
crawlingDeps: { startCrawl, stopCrawl, getSession, listSessions },
findingsDeps: { getFinding, listFindings, findingStats, resolveFinding, enrichFinding },
fuzzingDeps: { runFuzz, repository: fuzzRepo },
+ reportingDeps: { generateReport, reportRepository: reportRepo, jobQueue },
authDeps: {
registerCommand,
loginCommand,
@@ -130,11 +142,6 @@ async function bootstrap() {
},
});
const httpServer = http_1.default.createServer(app);
- // 11. Job queue
- const jobQueue = new SQLiteJobQueue_1.SQLiteJobQueue(db, logger, config.jobs.pollIntervalMs);
- jobQueue.registerHandler(ExplorationWorker_1.EXPLORATION_JOB_TYPE, (0, ExplorationWorker_1.createExplorationJobHandler)({ sessionRepo, eventBus, logger }));
- jobQueue.registerHandler(ReportWorker_1.REPORT_JOB_TYPE, (0, ReportWorker_1.createReportJobHandler)({ logger }));
- jobQueue.start();
// 12. Socket.io + gateway
const io = new socket_io_1.Server(httpServer, {
cors: { origin: config.cors.origin, credentials: true },
diff --git a/dist/modules/findings/infrastructure/http/FindingsController.js b/dist/modules/findings/infrastructure/http/FindingsController.js
index 44142e1..1a74d2a 100644
--- a/dist/modules/findings/infrastructure/http/FindingsController.js
+++ b/dist/modules/findings/infrastructure/http/FindingsController.js
@@ -38,7 +38,7 @@ function createFindingsRouter(deps) {
}
res.json(result.value);
});
- // GET /api/findings/:id — finding detail
+ // GET /api/findings/:id — finding detail (includes actionTrace)
router.get('/:id', async (req, res) => {
const findingId = req.params['id'];
const result = await deps.getFinding.execute({ findingId });
@@ -46,7 +46,8 @@ function createFindingsRouter(deps) {
res.status(404).json({ error: result.error });
return;
}
- res.json(toDTO(result.value));
+ const f = result.value;
+ res.json({ ...toDTO(f), actionTrace: f.actionTrace });
});
// PATCH /api/findings/:id/status — update status
router.patch('/:id/status', async (req, res) => {
diff --git a/dist/modules/reporting/application/commands/GenerateReportCommand.js b/dist/modules/reporting/application/commands/GenerateReportCommand.js
new file mode 100644
index 0000000..1ff601c
--- /dev/null
+++ b/dist/modules/reporting/application/commands/GenerateReportCommand.js
@@ -0,0 +1,33 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.GenerateReportCommand = void 0;
+const Result_1 = require("../../../../shared/domain/Result");
+const Report_1 = require("../../domain/entities/Report");
+const ReportFormat_1 = require("../../domain/value-objects/ReportFormat");
+class GenerateReportCommand {
+ constructor(reportRepository, eventBus) {
+ this.reportRepository = reportRepository;
+ this.eventBus = eventBus;
+ }
+ async execute(request) {
+ let format;
+ try {
+ format = ReportFormat_1.ReportFormat.fromString(request.format);
+ }
+ catch {
+ return (0, Result_1.Err)(`Invalid format: ${request.format}`);
+ }
+ const report = Report_1.Report.create({
+ title: request.title,
+ format,
+ filters: request.filters ?? {},
+ });
+ await this.reportRepository.save(report);
+ const events = report.clearEvents();
+ for (const event of events) {
+ await this.eventBus.publish(event);
+ }
+ return (0, Result_1.Ok)({ reportId: report.id.toString(), status: report.status.value });
+ }
+}
+exports.GenerateReportCommand = GenerateReportCommand;
diff --git a/dist/modules/reporting/domain/entities/Report.js b/dist/modules/reporting/domain/entities/Report.js
new file mode 100644
index 0000000..b1dbd74
--- /dev/null
+++ b/dist/modules/reporting/domain/entities/Report.js
@@ -0,0 +1,59 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.Report = void 0;
+const AggregateRoot_1 = require("../../../../shared/domain/AggregateRoot");
+const UniqueId_1 = require("../../../../shared/domain/UniqueId");
+const ReportStatus_1 = require("../value-objects/ReportStatus");
+const ReportRequested_1 = require("../events/ReportRequested");
+const ReportGenerated_1 = require("../events/ReportGenerated");
+const ReportFailed_1 = require("../events/ReportFailed");
+class Report extends AggregateRoot_1.AggregateRoot {
+ static create(props, id) {
+ const reportId = id ?? UniqueId_1.UniqueId.create();
+ const report = new Report({
+ ...props,
+ status: ReportStatus_1.ReportStatus.pending(),
+ totalFindings: 0,
+ createdAt: new Date(),
+ }, reportId);
+ report.addDomainEvent(new ReportRequested_1.ReportRequested(reportId.toString(), {
+ title: props.title,
+ format: props.format.value,
+ filters: props.filters,
+ }));
+ return report;
+ }
+ static reconstitute(props, id) {
+ return new Report(props, id);
+ }
+ get title() { return this.props.title; }
+ get format() { return this.props.format; }
+ get status() { return this.props.status; }
+ get filters() { return this.props.filters; }
+ get filePath() { return this.props.filePath; }
+ get errorMessage() { return this.props.errorMessage; }
+ get totalFindings() { return this.props.totalFindings; }
+ get createdAt() { return this.props.createdAt; }
+ get completedAt() { return this.props.completedAt; }
+ markGenerating() {
+ this.props.status = ReportStatus_1.ReportStatus.generating();
+ }
+ markReady(filePath, totalFindings) {
+ this.props.status = ReportStatus_1.ReportStatus.ready();
+ this.props.filePath = filePath;
+ this.props.totalFindings = totalFindings;
+ this.props.completedAt = new Date();
+ this.addDomainEvent(new ReportGenerated_1.ReportGenerated(this.id.toString(), {
+ filePath,
+ totalFindings,
+ format: this.props.format.value,
+ }));
+ }
+ markFailed(errorMessage) {
+ this.props.status = ReportStatus_1.ReportStatus.failed();
+ this.props.errorMessage = errorMessage;
+ this.props.completedAt = new Date();
+ this.addDomainEvent(new ReportFailed_1.ReportFailed(this.id.toString(), { errorMessage }));
+ }
+}
+exports.Report = Report;
diff --git a/dist/modules/reporting/domain/events/ReportFailed.js b/dist/modules/reporting/domain/events/ReportFailed.js
new file mode 100644
index 0000000..96e71d9
--- /dev/null
+++ b/dist/modules/reporting/domain/events/ReportFailed.js
@@ -0,0 +1,14 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.ReportFailed = void 0;
+const crypto_1 = require("crypto");
+class ReportFailed {
+ constructor(aggregateId, payload) {
+ this.aggregateId = aggregateId;
+ this.payload = payload;
+ this.eventId = (0, crypto_1.randomUUID)();
+ this.eventName = 'reporting.report_failed';
+ this.occurredOn = new Date();
+ }
+}
+exports.ReportFailed = ReportFailed;
diff --git a/dist/modules/reporting/domain/events/ReportGenerated.js b/dist/modules/reporting/domain/events/ReportGenerated.js
new file mode 100644
index 0000000..fa675ff
--- /dev/null
+++ b/dist/modules/reporting/domain/events/ReportGenerated.js
@@ -0,0 +1,14 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.ReportGenerated = void 0;
+const crypto_1 = require("crypto");
+class ReportGenerated {
+ constructor(aggregateId, payload) {
+ this.aggregateId = aggregateId;
+ this.payload = payload;
+ this.eventId = (0, crypto_1.randomUUID)();
+ this.eventName = 'reporting.report_generated';
+ this.occurredOn = new Date();
+ }
+}
+exports.ReportGenerated = ReportGenerated;
diff --git a/dist/modules/reporting/domain/events/ReportRequested.js b/dist/modules/reporting/domain/events/ReportRequested.js
new file mode 100644
index 0000000..7ace8f8
--- /dev/null
+++ b/dist/modules/reporting/domain/events/ReportRequested.js
@@ -0,0 +1,14 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.ReportRequested = void 0;
+const crypto_1 = require("crypto");
+class ReportRequested {
+ constructor(aggregateId, payload) {
+ this.aggregateId = aggregateId;
+ this.payload = payload;
+ this.eventId = (0, crypto_1.randomUUID)();
+ this.eventName = 'reporting.report_requested';
+ this.occurredOn = new Date();
+ }
+}
+exports.ReportRequested = ReportRequested;
diff --git a/dist/modules/reporting/domain/ports/IReportGenerator.js b/dist/modules/reporting/domain/ports/IReportGenerator.js
new file mode 100644
index 0000000..c8ad2e5
--- /dev/null
+++ b/dist/modules/reporting/domain/ports/IReportGenerator.js
@@ -0,0 +1,2 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
diff --git a/dist/modules/reporting/domain/ports/IReportRepository.js b/dist/modules/reporting/domain/ports/IReportRepository.js
new file mode 100644
index 0000000..c8ad2e5
--- /dev/null
+++ b/dist/modules/reporting/domain/ports/IReportRepository.js
@@ -0,0 +1,2 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
diff --git a/dist/modules/reporting/domain/value-objects/DateRange.js b/dist/modules/reporting/domain/value-objects/DateRange.js
new file mode 100644
index 0000000..48db9d8
--- /dev/null
+++ b/dist/modules/reporting/domain/value-objects/DateRange.js
@@ -0,0 +1,14 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.DateRange = void 0;
+const ValueObject_1 = require("../../../../shared/domain/ValueObject");
+class DateRange extends ValueObject_1.ValueObject {
+ get startDate() { return this.props.startDate; }
+ get endDate() { return this.props.endDate; }
+ static create(startDate, endDate) {
+ if (startDate > endDate)
+ throw new Error('startDate must be before endDate');
+ return new DateRange({ startDate, endDate });
+ }
+}
+exports.DateRange = DateRange;
diff --git a/dist/modules/reporting/domain/value-objects/ReportFormat.js b/dist/modules/reporting/domain/value-objects/ReportFormat.js
new file mode 100644
index 0000000..2cb6bb2
--- /dev/null
+++ b/dist/modules/reporting/domain/value-objects/ReportFormat.js
@@ -0,0 +1,17 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.ReportFormat = void 0;
+const ValueObject_1 = require("../../../../shared/domain/ValueObject");
+class ReportFormat extends ValueObject_1.ValueObject {
+ get value() { return this.props.value; }
+ static html() { return new ReportFormat({ value: 'html' }); }
+ static json() { return new ReportFormat({ value: 'json' }); }
+ static pdf() { return new ReportFormat({ value: 'pdf' }); }
+ static fromString(s) {
+ if (s === 'html' || s === 'json' || s === 'pdf') {
+ return new ReportFormat({ value: s });
+ }
+ throw new Error(`Invalid report format: ${s}`);
+ }
+}
+exports.ReportFormat = ReportFormat;
diff --git a/dist/modules/reporting/domain/value-objects/ReportStatus.js b/dist/modules/reporting/domain/value-objects/ReportStatus.js
new file mode 100644
index 0000000..8f18a61
--- /dev/null
+++ b/dist/modules/reporting/domain/value-objects/ReportStatus.js
@@ -0,0 +1,18 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.ReportStatus = void 0;
+const ValueObject_1 = require("../../../../shared/domain/ValueObject");
+class ReportStatus extends ValueObject_1.ValueObject {
+ get value() { return this.props.value; }
+ static pending() { return new ReportStatus({ value: 'pending' }); }
+ static generating() { return new ReportStatus({ value: 'generating' }); }
+ static ready() { return new ReportStatus({ value: 'ready' }); }
+ static failed() { return new ReportStatus({ value: 'failed' }); }
+ static fromString(s) {
+ if (s === 'pending' || s === 'generating' || s === 'ready' || s === 'failed') {
+ return new ReportStatus({ value: s });
+ }
+ throw new Error(`Invalid report status: ${s}`);
+ }
+}
+exports.ReportStatus = ReportStatus;
diff --git a/dist/modules/reporting/infrastructure/generators/HTMLReportGenerator.js b/dist/modules/reporting/infrastructure/generators/HTMLReportGenerator.js
new file mode 100644
index 0000000..a2ca203
--- /dev/null
+++ b/dist/modules/reporting/infrastructure/generators/HTMLReportGenerator.js
@@ -0,0 +1,138 @@
+"use strict";
+var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
+ if (k2 === undefined) k2 = k;
+ var desc = Object.getOwnPropertyDescriptor(m, k);
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
+ desc = { enumerable: true, get: function() { return m[k]; } };
+ }
+ Object.defineProperty(o, k2, desc);
+}) : (function(o, m, k, k2) {
+ if (k2 === undefined) k2 = k;
+ o[k2] = m[k];
+}));
+var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
+}) : function(o, v) {
+ o["default"] = v;
+});
+var __importStar = (this && this.__importStar) || (function () {
+ var ownKeys = function(o) {
+ ownKeys = Object.getOwnPropertyNames || function (o) {
+ var ar = [];
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
+ return ar;
+ };
+ return ownKeys(o);
+ };
+ return function (mod) {
+ if (mod && mod.__esModule) return mod;
+ var result = {};
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
+ __setModuleDefault(result, mod);
+ return result;
+ };
+})();
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.HTMLReportGenerator = void 0;
+const fs = __importStar(require("fs"));
+const path = __importStar(require("path"));
+class HTMLReportGenerator {
+ async generate(report, findings) {
+ const outputDir = path.join(process.cwd(), 'reports', report.id.toString());
+ fs.mkdirSync(outputDir, { recursive: true });
+ const severityCounts = { critical: 0, high: 0, medium: 0, low: 0 };
+ for (const f of findings) {
+ const sev = f.severity.value;
+ severityCounts[sev] = (severityCounts[sev] ?? 0) + 1;
+ }
+ const findingsHtml = findings.map(f => `
+
+
+
${escapeHtml(f.description)}
+
+ Session: ${f.sessionId} ·
+ ${new Date(f.createdAt).toLocaleString()}
+
+
+ `).join('\n');
+ const html = `
+
+
+
+
+ ${escapeHtml(report.title)}
+
+
+
+ ${escapeHtml(report.title)}
+
+ Generated by ABE · ${new Date().toLocaleString()}
+ ${report.filters.sessionId ? ` · Session: ${report.filters.sessionId}` : ''}
+
+
+
+
+
${findings.length}
+
Total
+
+
+
${severityCounts['critical'] ?? 0}
+
Critical
+
+
+
${severityCounts['high'] ?? 0}
+
High
+
+
+
${severityCounts['medium'] ?? 0}
+
Medium
+
+
+
${severityCounts['low'] ?? 0}
+
Low
+
+
+
+ Findings (${findings.length})
+ ${findings.length === 0 ? 'No findings match the selected filters.
' : findingsHtml}
+
+
+
+`;
+ const filePath = path.join(outputDir, `report.html`);
+ fs.writeFileSync(filePath, html, 'utf8');
+ return filePath;
+ }
+}
+exports.HTMLReportGenerator = HTMLReportGenerator;
+function escapeHtml(s) {
+ return s
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+}
diff --git a/dist/modules/reporting/infrastructure/generators/JSONReportGenerator.js b/dist/modules/reporting/infrastructure/generators/JSONReportGenerator.js
new file mode 100644
index 0000000..5881ec7
--- /dev/null
+++ b/dist/modules/reporting/infrastructure/generators/JSONReportGenerator.js
@@ -0,0 +1,88 @@
+"use strict";
+var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
+ if (k2 === undefined) k2 = k;
+ var desc = Object.getOwnPropertyDescriptor(m, k);
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
+ desc = { enumerable: true, get: function() { return m[k]; } };
+ }
+ Object.defineProperty(o, k2, desc);
+}) : (function(o, m, k, k2) {
+ if (k2 === undefined) k2 = k;
+ o[k2] = m[k];
+}));
+var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
+}) : function(o, v) {
+ o["default"] = v;
+});
+var __importStar = (this && this.__importStar) || (function () {
+ var ownKeys = function(o) {
+ ownKeys = Object.getOwnPropertyNames || function (o) {
+ var ar = [];
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
+ return ar;
+ };
+ return ownKeys(o);
+ };
+ return function (mod) {
+ if (mod && mod.__esModule) return mod;
+ var result = {};
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
+ __setModuleDefault(result, mod);
+ return result;
+ };
+})();
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.JSONReportGenerator = void 0;
+const fs = __importStar(require("fs"));
+const path = __importStar(require("path"));
+class JSONReportGenerator {
+ async generate(report, findings) {
+ const outputDir = path.join(process.cwd(), 'reports', report.id.toString());
+ fs.mkdirSync(outputDir, { recursive: true });
+ const data = {
+ reportId: report.id.toString(),
+ title: report.title,
+ generatedAt: new Date().toISOString(),
+ filters: report.filters,
+ summary: {
+ total: findings.length,
+ bySeverity: buildSeverityCount(findings),
+ byStatus: buildStatusCount(findings),
+ },
+ findings: findings.map(f => ({
+ id: f.id.toString(),
+ sessionId: f.sessionId,
+ type: f.type.value,
+ severity: f.severity.value,
+ description: f.description,
+ status: f.status.value,
+ browser: f.browser,
+ createdAt: f.createdAt.toISOString(),
+ resolvedAt: f.resolvedAt?.toISOString() ?? null,
+ evidence: f.evidence.toJSON(),
+ actionTraceLength: f.actionTrace.length,
+ })),
+ };
+ const filePath = path.join(outputDir, 'report.json');
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
+ return filePath;
+ }
+}
+exports.JSONReportGenerator = JSONReportGenerator;
+function buildSeverityCount(findings) {
+ const counts = {};
+ for (const f of findings) {
+ const s = f.severity.value;
+ counts[s] = (counts[s] ?? 0) + 1;
+ }
+ return counts;
+}
+function buildStatusCount(findings) {
+ const counts = {};
+ for (const f of findings) {
+ const s = f.status.value;
+ counts[s] = (counts[s] ?? 0) + 1;
+ }
+ return counts;
+}
diff --git a/dist/modules/reporting/infrastructure/generators/PDFReportGenerator.js b/dist/modules/reporting/infrastructure/generators/PDFReportGenerator.js
new file mode 100644
index 0000000..d44c28d
--- /dev/null
+++ b/dist/modules/reporting/infrastructure/generators/PDFReportGenerator.js
@@ -0,0 +1,81 @@
+"use strict";
+var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
+ if (k2 === undefined) k2 = k;
+ var desc = Object.getOwnPropertyDescriptor(m, k);
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
+ desc = { enumerable: true, get: function() { return m[k]; } };
+ }
+ Object.defineProperty(o, k2, desc);
+}) : (function(o, m, k, k2) {
+ if (k2 === undefined) k2 = k;
+ o[k2] = m[k];
+}));
+var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
+}) : function(o, v) {
+ o["default"] = v;
+});
+var __importStar = (this && this.__importStar) || (function () {
+ var ownKeys = function(o) {
+ ownKeys = Object.getOwnPropertyNames || function (o) {
+ var ar = [];
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
+ return ar;
+ };
+ return ownKeys(o);
+ };
+ return function (mod) {
+ if (mod && mod.__esModule) return mod;
+ var result = {};
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
+ __setModuleDefault(result, mod);
+ return result;
+ };
+})();
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.PDFReportGenerator = void 0;
+const path = __importStar(require("path"));
+const fs = __importStar(require("fs"));
+const HTMLReportGenerator_1 = require("./HTMLReportGenerator");
+/**
+ * PDF report generator — uses Playwright to render the HTML report to PDF.
+ * Requires Playwright + Chromium to be installed.
+ */
+class PDFReportGenerator {
+ constructor() {
+ this.htmlGenerator = new HTMLReportGenerator_1.HTMLReportGenerator();
+ }
+ async generate(report, findings) {
+ // First generate the HTML version
+ const htmlPath = await this.htmlGenerator.generate(report, findings);
+ const outputDir = path.dirname(htmlPath);
+ const pdfPath = path.join(outputDir, 'report.pdf');
+ // Use Playwright to convert HTML to PDF
+ let chromium;
+ try {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const pw = require('playwright');
+ chromium = pw.chromium;
+ }
+ catch {
+ throw new Error('Playwright not available — install playwright to generate PDF reports');
+ }
+ const browser = await chromium.launch({ headless: true });
+ try {
+ const page = await browser.newPage();
+ const htmlContent = fs.readFileSync(htmlPath, 'utf8');
+ await page.setContent(htmlContent, { waitUntil: 'networkidle' });
+ await page.pdf({
+ path: pdfPath,
+ format: 'A4',
+ printBackground: true,
+ margin: { top: '1cm', bottom: '1cm', left: '1cm', right: '1cm' },
+ });
+ }
+ finally {
+ await browser.close();
+ }
+ return pdfPath;
+ }
+}
+exports.PDFReportGenerator = PDFReportGenerator;
diff --git a/dist/modules/reporting/infrastructure/http/ReportingController.js b/dist/modules/reporting/infrastructure/http/ReportingController.js
new file mode 100644
index 0000000..3cafdff
--- /dev/null
+++ b/dist/modules/reporting/infrastructure/http/ReportingController.js
@@ -0,0 +1,134 @@
+"use strict";
+var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
+ if (k2 === undefined) k2 = k;
+ var desc = Object.getOwnPropertyDescriptor(m, k);
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
+ desc = { enumerable: true, get: function() { return m[k]; } };
+ }
+ Object.defineProperty(o, k2, desc);
+}) : (function(o, m, k, k2) {
+ if (k2 === undefined) k2 = k;
+ o[k2] = m[k];
+}));
+var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
+}) : function(o, v) {
+ o["default"] = v;
+});
+var __importStar = (this && this.__importStar) || (function () {
+ var ownKeys = function(o) {
+ ownKeys = Object.getOwnPropertyNames || function (o) {
+ var ar = [];
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
+ return ar;
+ };
+ return ownKeys(o);
+ };
+ return function (mod) {
+ if (mod && mod.__esModule) return mod;
+ var result = {};
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
+ __setModuleDefault(result, mod);
+ return result;
+ };
+})();
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.createReportingRouter = createReportingRouter;
+const express_1 = require("express");
+const path = __importStar(require("path"));
+const fs = __importStar(require("fs"));
+const ReportWorker_1 = require("../../../../jobs/workers/ReportWorker");
+function createReportingRouter(deps) {
+ const router = (0, express_1.Router)();
+ // POST /api/reports — create and enqueue report
+ router.post('/', async (req, res) => {
+ const { title, format, filters } = req.body;
+ if (!title || !format) {
+ res.status(400).json({ error: 'title and format are required' });
+ return;
+ }
+ const result = await deps.generateReport.execute({
+ title,
+ format: format,
+ filters: filters
+ ? {
+ sessionId: filters.sessionId,
+ startDate: filters.startDate ? new Date(filters.startDate) : undefined,
+ endDate: filters.endDate ? new Date(filters.endDate) : undefined,
+ severity: filters.severity,
+ }
+ : undefined,
+ });
+ if (!result.ok) {
+ res.status(400).json({ error: result.error });
+ return;
+ }
+ // Enqueue background job
+ await deps.jobQueue.enqueue(ReportWorker_1.REPORT_JOB_TYPE, {
+ reportId: result.value.reportId,
+ format: format,
+ filters: filters,
+ });
+ res.status(201).json(result.value);
+ });
+ // GET /api/reports — list all reports
+ router.get('/', async (_req, res) => {
+ const reports = await deps.reportRepository.findAll();
+ res.json(reports.map(r => ({
+ id: r.id.toString(),
+ title: r.title,
+ format: r.format.value,
+ status: r.status.value,
+ totalFindings: r.totalFindings,
+ createdAt: r.createdAt.toISOString(),
+ completedAt: r.completedAt?.toISOString() ?? null,
+ })));
+ });
+ // GET /api/reports/:id — report detail
+ router.get('/:id', async (req, res) => {
+ const report = await deps.reportRepository.findById(req.params['id']);
+ if (!report) {
+ res.status(404).json({ error: 'Report not found' });
+ return;
+ }
+ res.json({
+ id: report.id.toString(),
+ title: report.title,
+ format: report.format.value,
+ status: report.status.value,
+ filters: report.filters,
+ totalFindings: report.totalFindings,
+ errorMessage: report.errorMessage,
+ createdAt: report.createdAt.toISOString(),
+ completedAt: report.completedAt?.toISOString() ?? null,
+ });
+ });
+ // GET /api/reports/:id/download — download the generated file
+ router.get('/:id/download', async (req, res) => {
+ const report = await deps.reportRepository.findById(req.params['id']);
+ if (!report) {
+ res.status(404).json({ error: 'Report not found' });
+ return;
+ }
+ if (report.status.value !== 'ready' || !report.filePath) {
+ res.status(409).json({ error: 'Report is not ready yet', status: report.status.value });
+ return;
+ }
+ if (!fs.existsSync(report.filePath)) {
+ res.status(410).json({ error: 'Report file no longer exists' });
+ return;
+ }
+ const ext = path.extname(report.filePath);
+ const contentTypes = {
+ '.html': 'text/html',
+ '.json': 'application/json',
+ '.pdf': 'application/pdf',
+ };
+ const contentType = contentTypes[ext] ?? 'application/octet-stream';
+ const filename = `report-${report.id.toString()}${ext}`;
+ res.setHeader('Content-Type', contentType);
+ res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
+ fs.createReadStream(report.filePath).pipe(res);
+ });
+ return router;
+}
diff --git a/dist/modules/reporting/infrastructure/repositories/KyselyReportRepository.js b/dist/modules/reporting/infrastructure/repositories/KyselyReportRepository.js
new file mode 100644
index 0000000..416e37e
--- /dev/null
+++ b/dist/modules/reporting/infrastructure/repositories/KyselyReportRepository.js
@@ -0,0 +1,85 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.KyselyReportRepository = void 0;
+const Report_1 = require("../../domain/entities/Report");
+const UniqueId_1 = require("../../../../shared/domain/UniqueId");
+const ReportFormat_1 = require("../../domain/value-objects/ReportFormat");
+const ReportStatus_1 = require("../../domain/value-objects/ReportStatus");
+class KyselyReportRepository {
+ constructor(db) {
+ this.db = db;
+ }
+ async save(report) {
+ const row = {
+ id: report.id.toString(),
+ title: report.title,
+ format: report.format.value,
+ status: report.status.value,
+ filters_json: JSON.stringify(report.filters),
+ file_path: report.filePath ?? null,
+ error_message: report.errorMessage ?? null,
+ total_findings: report.totalFindings,
+ created_at: report.createdAt.getTime(),
+ completed_at: report.completedAt ? report.completedAt.getTime() : null,
+ };
+ await this.db.insertInto('reports').values(row).execute();
+ }
+ async findById(id) {
+ const row = await this.db
+ .selectFrom('reports')
+ .selectAll()
+ .where('id', '=', id)
+ .executeTakeFirst();
+ return row ? this.toDomain(row) : undefined;
+ }
+ async findAll() {
+ const rows = await this.db
+ .selectFrom('reports')
+ .selectAll()
+ .orderBy('created_at', 'desc')
+ .execute();
+ return rows.map(r => this.toDomain(r));
+ }
+ async update(report) {
+ await this.db
+ .updateTable('reports')
+ .set({
+ status: report.status.value,
+ file_path: report.filePath ?? null,
+ error_message: report.errorMessage ?? null,
+ total_findings: report.totalFindings,
+ completed_at: report.completedAt ? report.completedAt.getTime() : null,
+ })
+ .where('id', '=', report.id.toString())
+ .execute();
+ }
+ toDomain(row) {
+ const filters = this.parseJson(row.filters_json, {});
+ const props = {
+ title: row.title,
+ format: ReportFormat_1.ReportFormat.fromString(row.format),
+ status: ReportStatus_1.ReportStatus.fromString(row.status),
+ filters: {
+ sessionId: filters.sessionId,
+ startDate: filters.startDate ? new Date(filters.startDate) : undefined,
+ endDate: filters.endDate ? new Date(filters.endDate) : undefined,
+ severity: filters.severity,
+ },
+ filePath: row.file_path ?? undefined,
+ errorMessage: row.error_message ?? undefined,
+ totalFindings: row.total_findings,
+ createdAt: new Date(row.created_at),
+ completedAt: row.completed_at ? new Date(row.completed_at) : undefined,
+ };
+ return Report_1.Report.reconstitute(props, UniqueId_1.UniqueId.from(row.id));
+ }
+ parseJson(json, fallback) {
+ try {
+ return JSON.parse(json);
+ }
+ catch {
+ return fallback;
+ }
+ }
+}
+exports.KyselyReportRepository = KyselyReportRepository;
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index ad999fe..13ee5d7 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -11,21 +11,21 @@ import { Setup } from '@/pages/Setup'
import { Dashboard } from '@/pages/Dashboard'
import { SessionList } from '@/pages/sessions/SessionList'
import { SessionDetail } from '@/pages/sessions/SessionDetail'
-function FindingsList() {
- return Findings — Coming in Phase 13
-}
-function FindingDetail() {
- return Finding Detail — Coming in Phase 13
-}
-function Reports() {
- return Reports — Coming in Phase 15
-}
+import { FindingsList } from '@/pages/findings/FindingsList'
+import { FindingDetail } from '@/pages/findings/FindingDetail'
+import { SettingsLayout } from '@/pages/settings/SettingsLayout'
+import { ProfileSection } from '@/pages/settings/ProfileSection'
+import { OrganizationSection } from '@/pages/settings/OrganizationSection'
+import { ApiKeysSection } from '@/pages/settings/ApiKeysSection'
+import { ExplorationDefaultsSection } from '@/pages/settings/ExplorationDefaultsSection'
+import { NotificationsSection } from '@/pages/settings/NotificationsSection'
+import { AppearanceSection } from '@/pages/settings/AppearanceSection'
+import { LicenseSection } from '@/pages/settings/LicenseSection'
+import { Reports } from '@/pages/Reports'
+
function VisualReview() {
return Visual Review — Coming in Phase 20
}
-function Settings() {
- return Settings — Coming in Phase 14
-}
export default function App() {
return (
@@ -50,7 +50,16 @@ export default function App() {
} />
} />
} />
- } />
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
} />
diff --git a/frontend/src/components/findings/AIAnalysisPanel.tsx b/frontend/src/components/findings/AIAnalysisPanel.tsx
new file mode 100644
index 0000000..db39dd4
--- /dev/null
+++ b/frontend/src/components/findings/AIAnalysisPanel.tsx
@@ -0,0 +1,122 @@
+import { useState } from 'react'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import { Skeleton } from '@/components/ui/skeleton'
+import { Sparkles } from 'lucide-react'
+import { apiFetch } from '@/lib/api'
+import { useQueryClient } from '@tanstack/react-query'
+import type { AIEnrichment } from '../../types'
+
+interface AIAnalysisPanelProps {
+ findingId: string
+ enrichment?: AIEnrichment | null
+}
+
+const CONFIDENCE_COLOR: Record = {
+ high: 'bg-green-500/15 text-green-600 border-green-500/30',
+ medium: 'bg-yellow-500/15 text-yellow-600 border-yellow-500/30',
+ low: 'bg-red-500/15 text-red-500 border-red-500/30',
+}
+
+export function AIAnalysisPanel({ findingId, enrichment }: AIAnalysisPanelProps) {
+ const queryClient = useQueryClient()
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+
+ async function handleAnalyze() {
+ setLoading(true)
+ setError(null)
+ try {
+ await apiFetch(`/api/findings/${findingId}/enrich`, { method: 'POST' })
+ await queryClient.invalidateQueries({ queryKey: ['findings', findingId] })
+ } catch (e) {
+ setError(e instanceof Error ? e.message : 'Analysis failed')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ if (loading) {
+ return (
+
+
+
+
+ AI Analysis
+
+
+
+
+
+
+
+
+ )
+ }
+
+ if (!enrichment) {
+ return (
+
+
+
+
+ AI Analysis
+
+
+
+ {error && {error}
}
+
+ Get AI-powered root cause analysis, user impact assessment, and suggested fixes.
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
+ AI Analysis
+
+
+ {enrichment.confidence} confidence
+
+
+
+ {enrichment.provider} / {enrichment.model} ·{' '}
+ {new Date(enrichment.generatedAt).toLocaleString()}
+
+
+
+
+
Root Cause
+
{enrichment.rootCause}
+
+
+
User Impact
+
{enrichment.userImpact}
+
+
+
Suggested Fix
+
{enrichment.suggestedFix}
+
+ {enrichment.debugPrompt && (
+
+
Debug Prompt
+
+ {enrichment.debugPrompt}
+
+
+ )}
+
+
+ )
+}
diff --git a/frontend/src/components/findings/EvidencePanel.tsx b/frontend/src/components/findings/EvidencePanel.tsx
new file mode 100644
index 0000000..e3f3e7d
--- /dev/null
+++ b/frontend/src/components/findings/EvidencePanel.tsx
@@ -0,0 +1,78 @@
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
+import { ScrollArea } from '@/components/ui/scroll-area'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import type { AnomalyEvidence } from '../../types'
+
+interface EvidencePanelProps {
+ evidence: AnomalyEvidence
+}
+
+export function EvidencePanel({ evidence }: EvidencePanelProps) {
+ return (
+
+
+ Console
+ Network
+ {evidence.domSnapshotPath && DOM}
+
+
+
+ {evidence.rawErrors && evidence.rawErrors.length > 0 ? (
+
+ {evidence.rawErrors.map((err, i) => (
+ {err}
+ ))}
+
+ ) : (
+ No console errors captured.
+ )}
+
+
+
+ {evidence.httpLog && evidence.httpLog.length > 0 ? (
+
+
+
+
+ Method
+ Status
+ URL
+ Duration
+
+
+
+ {evidence.httpLog.map((req, i) => (
+
+ {req.method}
+ = 400 ? 'text-destructive' : ''}`}>
+ {req.status}
+
+ {req.url}
+ {req.durationMs}ms
+
+ ))}
+
+
+
+ ) : (
+ No network requests captured.
+ )}
+
+
+ {evidence.domSnapshotPath && (
+
+
+
DOM snapshot: {evidence.domSnapshotPath}
+
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/findings/ReproductionSteps.tsx b/frontend/src/components/findings/ReproductionSteps.tsx
new file mode 100644
index 0000000..5d2ddc8
--- /dev/null
+++ b/frontend/src/components/findings/ReproductionSteps.tsx
@@ -0,0 +1,62 @@
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import type { Action } from '../../types'
+
+interface ReproductionStepsProps {
+ steps: Action[]
+}
+
+const ACTION_ICONS: Record = {
+ click: '🖱️',
+ fill: '⌨️',
+ navigate: '🔗',
+ scroll: '📜',
+ hover: '🎯',
+ select: '📋',
+ press: '⌨️',
+}
+
+export function ReproductionSteps({ steps }: ReproductionStepsProps) {
+ if (steps.length === 0) {
+ return (
+
+
+ Reproduction Steps
+
+
+ No action trace available.
+
+
+ )
+ }
+
+ return (
+
+
+ Reproduction Steps
+
+
+
+ {steps.map((step, i) => (
+ -
+ {i + 1}.
+ {ACTION_ICONS[step.type] ?? '▶️'}
+
+ {step.type}
+ {step.selector && (
+
+ {step.selector}
+
+ )}
+ {step.value && (
+
+ value: "{step.value}"
+
+ )}
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts
index b6d2485..65aa25e 100644
--- a/frontend/src/hooks/useAuth.ts
+++ b/frontend/src/hooks/useAuth.ts
@@ -6,6 +6,7 @@ interface User {
email: string
name: string
role: string
+ orgId?: string
}
export function useAuth() {
diff --git a/frontend/src/hooks/useFindings.ts b/frontend/src/hooks/useFindings.ts
index 14cf654..f002597 100644
--- a/frontend/src/hooks/useFindings.ts
+++ b/frontend/src/hooks/useFindings.ts
@@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query'
import { apiFetch } from '@/lib/api'
-import type { AnomalySummary, Stats } from '../types'
+import type { AnomalySummary, Finding, Stats } from '../types'
export function useFindings(params?: { sessionId?: string; severity?: string }) {
const qs = new URLSearchParams()
@@ -14,6 +14,14 @@ export function useFindings(params?: { sessionId?: string; severity?: string })
})
}
+export function useFinding(id: string) {
+ return useQuery({
+ queryKey: ['findings', id],
+ queryFn: () => apiFetch(`/api/findings/${id}`),
+ enabled: !!id,
+ })
+}
+
export function useFindingStats() {
return useQuery({
queryKey: ['findings', 'stats'],
diff --git a/frontend/src/pages/Reports.tsx b/frontend/src/pages/Reports.tsx
new file mode 100644
index 0000000..078721d
--- /dev/null
+++ b/frontend/src/pages/Reports.tsx
@@ -0,0 +1,257 @@
+import { useState } from 'react'
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import { apiFetch } from '@/lib/api'
+import { Button } from '@/components/ui/button'
+import { Badge } from '@/components/ui/badge'
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogFooter,
+} from '@/components/ui/dialog'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import { toast } from 'sonner'
+
+interface Report {
+ id: string
+ title: string
+ format: 'html' | 'json' | 'pdf'
+ status: 'pending' | 'generating' | 'ready' | 'failed'
+ totalFindings: number
+ createdAt: string
+ completedAt: string | null
+}
+
+interface GenerateForm {
+ title: string
+ format: 'html' | 'json' | 'pdf'
+ sessionId: string
+ startDate: string
+ endDate: string
+ severity: string
+}
+
+const STATUS_COLORS: Record = {
+ pending: 'secondary',
+ generating: 'secondary',
+ ready: 'default',
+ failed: 'destructive',
+}
+
+function formatDate(iso: string) {
+ return new Date(iso).toLocaleString()
+}
+
+export function Reports() {
+ const qc = useQueryClient()
+ const [open, setOpen] = useState(false)
+ const [form, setForm] = useState({
+ title: '',
+ format: 'html',
+ sessionId: '',
+ startDate: '',
+ endDate: '',
+ severity: '',
+ })
+
+ const { data: reports = [], isLoading } = useQuery({
+ queryKey: ['reports'],
+ queryFn: () => apiFetch('/api/reports'),
+ refetchInterval: 5000,
+ })
+
+ const generate = useMutation({
+ mutationFn: (body: object) =>
+ apiFetch<{ reportId: string }>('/api/reports', {
+ method: 'POST',
+ body: JSON.stringify(body),
+ }),
+ onSuccess: () => {
+ toast.success('Report queued — it will be ready shortly')
+ setOpen(false)
+ void qc.invalidateQueries({ queryKey: ['reports'] })
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ function handleSubmit() {
+ if (!form.title) { toast.error('Title is required'); return }
+ const filters: Record = {}
+ if (form.sessionId) filters['sessionId'] = form.sessionId
+ if (form.startDate) filters['startDate'] = form.startDate
+ if (form.endDate) filters['endDate'] = form.endDate
+ if (form.severity) filters['severity'] = form.severity
+ generate.mutate({ title: form.title, format: form.format, filters })
+ }
+
+ function handleDownload(report: Report) {
+ window.open(`/api/reports/${report.id}/download`, '_blank')
+ }
+
+ return (
+
+
+
+
Reports
+
+ Generate and download bug reports in multiple formats.
+
+
+
+
+
+ {isLoading ? (
+
Loading...
+ ) : reports.length === 0 ? (
+
+
No reports yet.
+
Click "Generate Report" to create one.
+
+ ) : (
+
+
+
+ Title
+ Format
+ Status
+ Findings
+ Created
+ Completed
+
+
+
+
+ {reports.map(r => (
+
+ {r.title}
+
+ {r.format.toUpperCase()}
+
+
+
+ {r.status}
+
+
+ {r.status === 'ready' ? r.totalFindings : '—'}
+
+ {formatDate(r.createdAt)}
+
+
+ {r.completedAt ? formatDate(r.completedAt) : '—'}
+
+
+ {r.status === 'ready' && (
+
+ )}
+
+
+ ))}
+
+
+ )}
+
+
+
+ )
+}
diff --git a/frontend/src/pages/findings/FindingDetail.tsx b/frontend/src/pages/findings/FindingDetail.tsx
new file mode 100644
index 0000000..db73ab2
--- /dev/null
+++ b/frontend/src/pages/findings/FindingDetail.tsx
@@ -0,0 +1,176 @@
+import { useParams, useNavigate } from 'react-router-dom'
+import { useQueryClient } from '@tanstack/react-query'
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
+import { Button } from '@/components/ui/button'
+import { Badge } from '@/components/ui/badge'
+import { Skeleton } from '@/components/ui/skeleton'
+import { ArrowLeft, Download, FileText, FileJson, Code2, CheckCircle2, Search, XCircle } from 'lucide-react'
+import { SeverityBadge } from '@/components/common/SeverityBadge'
+import { ReproductionSteps } from '@/components/findings/ReproductionSteps'
+import { EvidencePanel } from '@/components/findings/EvidencePanel'
+import { AIAnalysisPanel } from '@/components/findings/AIAnalysisPanel'
+import { useFinding } from '@/hooks/useFindings'
+import { apiFetch } from '@/lib/api'
+import type { FindingStatus } from '../../types'
+
+const STATUS_COLOR: Record = {
+ open: 'bg-red-500/15 text-red-500 border-red-500/30',
+ investigating: 'bg-yellow-500/15 text-yellow-600 border-yellow-500/30',
+ resolved: 'bg-green-500/15 text-green-600 border-green-500/30',
+ closed: 'bg-slate-500/15 text-slate-500 border-slate-500/30',
+}
+
+const STATUS_TRANSITIONS: Record> = {
+ open: [
+ { action: 'investigate', label: 'Investigate', icon: Search },
+ { action: 'resolve', label: 'Resolve', icon: CheckCircle2 },
+ ],
+ investigating: [
+ { action: 'resolve', label: 'Resolve', icon: CheckCircle2 },
+ { action: 'close', label: 'Close', icon: XCircle },
+ ],
+ resolved: [
+ { action: 'close', label: 'Close', icon: XCircle },
+ ],
+ closed: [],
+}
+
+function downloadHref(findingId: string, format: 'markdown' | 'json' | 'playwright') {
+ const API_URL = import.meta.env.VITE_API_URL || ''
+ return `${API_URL}/api/findings/${findingId}/export/${format}`
+}
+
+export function FindingDetail() {
+ const { id } = useParams<{ id: string }>()
+ const navigate = useNavigate()
+ const queryClient = useQueryClient()
+ const { data: finding, isLoading } = useFinding(id ?? '')
+
+ async function handleStatusChange(action: 'investigate' | 'resolve' | 'close') {
+ if (!id) return
+ try {
+ await apiFetch(`/api/findings/${id}/status`, {
+ method: 'PATCH',
+ body: JSON.stringify({ action }),
+ })
+ await queryClient.invalidateQueries({ queryKey: ['findings', id] })
+ await queryClient.invalidateQueries({ queryKey: ['findings'] })
+ } catch (e) {
+ console.error('Failed to update status:', e)
+ }
+ }
+
+ if (isLoading) {
+ return (
+
+
+
+
+
+ )
+ }
+
+ if (!finding) {
+ return Finding not found.
+ }
+
+ const transitions = STATUS_TRANSITIONS[finding.status] ?? []
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+ {finding.status}
+
+ {finding.type}
+
+
{finding.description}
+
+ {finding.browser && {finding.browser} · }
+ Found {new Date(finding.createdAt).toLocaleString()}
+ {finding.resolvedAt && ` · Resolved ${new Date(finding.resolvedAt).toLocaleString()}`}
+
+
+
+
+ {/* Action bar */}
+
+
+ {/* Main split layout */}
+
+ {/* Left: Evidence + Reproduction */}
+
+
+
+ Evidence
+
+ Steps
+ {finding.actionTrace.length > 0 && (
+ {finding.actionTrace.length}
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ {/* Right: AI Analysis */}
+
+
+
+ )
+}
diff --git a/frontend/src/pages/findings/FindingsList.tsx b/frontend/src/pages/findings/FindingsList.tsx
new file mode 100644
index 0000000..6957bb5
--- /dev/null
+++ b/frontend/src/pages/findings/FindingsList.tsx
@@ -0,0 +1,184 @@
+import { useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import {
+ useReactTable,
+ getCoreRowModel,
+ getSortedRowModel,
+ getFilteredRowModel,
+ flexRender,
+ type ColumnDef,
+ type SortingState,
+} from '@tanstack/react-table'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import { Skeleton } from '@/components/ui/skeleton'
+import { ArrowUpDown, X } from 'lucide-react'
+import { SeverityBadge } from '@/components/common/SeverityBadge'
+import { useFindings } from '@/hooks/useFindings'
+import type { AnomalySummary, Severity } from '../../types'
+
+const SEVERITIES: Severity[] = ['critical', 'high', 'medium', 'low']
+
+const columns: ColumnDef[] = [
+ {
+ accessorKey: 'severity',
+ header: 'Severity',
+ cell: ({ row }) => ,
+ },
+ {
+ accessorKey: 'type',
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }) => {row.original.type},
+ },
+ {
+ accessorKey: 'description',
+ header: 'Description',
+ cell: ({ row }) => (
+
+ {row.original.description}
+
+ ),
+ },
+ {
+ accessorKey: 'timestamp',
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }) => (
+
+ {new Date(row.original.timestamp).toLocaleString()}
+
+ ),
+ },
+]
+
+export function FindingsList() {
+ const navigate = useNavigate()
+ const [severity, setSeverity] = useState('all')
+ const [search, setSearch] = useState('')
+ const [sorting, setSorting] = useState([{ id: 'timestamp', desc: true }])
+
+ const { data: allFindings = [], isLoading } = useFindings(
+ severity !== 'all' ? { severity } : undefined
+ )
+
+ const table = useReactTable({
+ data: allFindings,
+ columns,
+ state: { sorting, globalFilter: search },
+ onSortingChange: setSorting,
+ onGlobalFilterChange: setSearch,
+ getCoreRowModel: getCoreRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ })
+
+ return (
+
+
+
+
Findings
+
{table.getRowModel().rows.length} findings
+
+
+
+ {/* Filters */}
+
+ setSearch(e.target.value)}
+ className="max-w-xs"
+ />
+
+ {(severity !== 'all' || search) && (
+
+ )}
+
+
+ {isLoading ? (
+
+ {[1, 2, 3, 4, 5].map(i => )}
+
+ ) : (
+
+
+
+ {table.getHeaderGroups().map(hg => (
+
+ {hg.headers.map(h => (
+
+ {flexRender(h.column.columnDef.header, h.getContext())}
+
+ ))}
+
+ ))}
+
+
+ {table.getRowModel().rows.length === 0 ? (
+
+
+ No findings yet. Start an exploration!
+
+
+ ) : (
+ table.getRowModel().rows.map(row => (
+ navigate(`/findings/${row.original.id}`)}
+ >
+ {row.getVisibleCells().map(cell => (
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+
+ ))}
+
+ ))
+ )}
+
+
+
+ )}
+
+ )
+}
diff --git a/frontend/src/pages/settings/ApiKeysSection.tsx b/frontend/src/pages/settings/ApiKeysSection.tsx
new file mode 100644
index 0000000..ceb4db7
--- /dev/null
+++ b/frontend/src/pages/settings/ApiKeysSection.tsx
@@ -0,0 +1,190 @@
+import { useState } from 'react'
+import { useQuery, useQueryClient } from '@tanstack/react-query'
+import { apiFetch } from '@/lib/api'
+import { Card, CardContent } from '@/components/ui/card'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '@/components/ui/dialog'
+import { Key, Trash2, Copy, CheckCircle2 } from 'lucide-react'
+
+interface ApiKey {
+ id: string
+ name: string
+ keyPrefix: string
+ permissions: string[]
+ expiresAt: string | null
+ lastUsedAt: string | null
+ createdAt: string
+}
+
+interface CreatedKey {
+ id: string
+ name: string
+ keyPrefix: string
+ token: string
+}
+
+export function ApiKeysSection() {
+ const queryClient = useQueryClient()
+ const [open, setOpen] = useState(false)
+ const [name, setName] = useState('')
+ const [submitting, setSubmitting] = useState(false)
+ const [created, setCreated] = useState(null)
+ const [copied, setCopied] = useState(false)
+ const [error, setError] = useState(null)
+
+ const { data: keys = [], isLoading } = useQuery({
+ queryKey: ['api-keys'],
+ queryFn: () => apiFetch('/api/auth/api-keys'),
+ })
+
+ async function handleCreate(e: React.FormEvent) {
+ e.preventDefault()
+ if (!name) return
+ setSubmitting(true)
+ setError(null)
+ try {
+ const result = await apiFetch('/api/auth/api-keys', {
+ method: 'POST',
+ body: JSON.stringify({ name, permissions: ['*'] }),
+ })
+ setCreated(result)
+ setName('')
+ await queryClient.invalidateQueries({ queryKey: ['api-keys'] })
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Create failed')
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ async function handleRevoke(id: string) {
+ try {
+ await apiFetch(`/api/auth/api-keys/${id}`, { method: 'DELETE' })
+ await queryClient.invalidateQueries({ queryKey: ['api-keys'] })
+ } catch (err) {
+ console.error('Revoke failed:', err)
+ }
+ }
+
+ function copyToken() {
+ if (created?.token) {
+ void navigator.clipboard.writeText(created.token)
+ setCopied(true)
+ setTimeout(() => setCopied(false), 2000)
+ }
+ }
+
+ function handleDialogChange(v: boolean) {
+ setOpen(v)
+ if (!v) {
+ setCreated(null)
+ setError(null)
+ }
+ }
+
+ return (
+
+
+
+
API Keys
+
Manage API keys for programmatic access.
+
+
+
+
+
+
+ {isLoading ? (
+ Loading...
+ ) : keys.length === 0 ? (
+ No API keys yet.
+ ) : (
+
+ {keys.map(k => (
+
+
+
+
{k.name}
+
{k.keyPrefix}***
+
+ {k.lastUsedAt && (
+
+ Last used {new Date(k.lastUsedAt).toLocaleDateString()}
+
+ )}
+
+
+ ))}
+
+ )}
+
+
+
+ )
+}
diff --git a/frontend/src/pages/settings/AppearanceSection.tsx b/frontend/src/pages/settings/AppearanceSection.tsx
new file mode 100644
index 0000000..180590c
--- /dev/null
+++ b/frontend/src/pages/settings/AppearanceSection.tsx
@@ -0,0 +1,32 @@
+import { useTheme } from '@/components/layout/ThemeProvider'
+import { Card, CardContent } from '@/components/ui/card'
+import { Label } from '@/components/ui/label'
+import { Switch } from '@/components/ui/switch'
+
+export function AppearanceSection() {
+ const { theme, toggleTheme } = useTheme()
+
+ return (
+
+
+
Appearance
+
Customize how ABE looks.
+
+
+
+
+
+
+
+
Toggle between dark and light theme.
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/settings/ExplorationDefaultsSection.tsx b/frontend/src/pages/settings/ExplorationDefaultsSection.tsx
new file mode 100644
index 0000000..1b7f6a8
--- /dev/null
+++ b/frontend/src/pages/settings/ExplorationDefaultsSection.tsx
@@ -0,0 +1,104 @@
+import { useState } from 'react'
+import { useQuery, useQueryClient } from '@tanstack/react-query'
+import { apiFetch } from '@/lib/api'
+import { Card, CardContent } from '@/components/ui/card'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import type { ServerConfig } from '@/types'
+
+export function ExplorationDefaultsSection() {
+ const queryClient = useQueryClient()
+ const { data: config, isLoading } = useQuery({
+ queryKey: ['config'],
+ queryFn: () => apiFetch('/api/config'),
+ })
+
+ const [saving, setSaving] = useState(false)
+ const [maxStates, setMaxStates] = useState()
+ const [maxDepth, setMaxDepth] = useState()
+ const [actionDelayMs, setActionDelayMs] = useState()
+
+ const effectiveMaxStates = maxStates ?? config?.defaultMaxStates ?? 50
+ const effectiveMaxDepth = maxDepth ?? config?.defaultMaxDepth ?? 5
+ const effectiveDelay = actionDelayMs ?? config?.defaultActionDelayMs ?? 500
+
+ async function handleSave(e: React.FormEvent) {
+ e.preventDefault()
+ setSaving(true)
+ try {
+ await apiFetch('/api/config', {
+ method: 'PATCH',
+ body: JSON.stringify({
+ defaultMaxStates: effectiveMaxStates,
+ defaultMaxDepth: effectiveMaxDepth,
+ defaultActionDelayMs: effectiveDelay,
+ }),
+ })
+ await queryClient.invalidateQueries({ queryKey: ['config'] })
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ if (isLoading) return Loading...
+
+ return (
+
+
+
Exploration Defaults
+
Default values for new explorations.
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/settings/LicenseSection.tsx b/frontend/src/pages/settings/LicenseSection.tsx
new file mode 100644
index 0000000..3819e25
--- /dev/null
+++ b/frontend/src/pages/settings/LicenseSection.tsx
@@ -0,0 +1,32 @@
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+import { Badge } from '@/components/ui/badge'
+import { Shield } from 'lucide-react'
+
+export function LicenseSection() {
+ return (
+
+
+
License
+
Manage your ABE license.
+
+
+
+
+
+
+ Current Plan
+
+
+
+
+ Plan
+ Free / OSS
+
+
+ License activation will be available in Phase 17 (RSA-signed keys with feature entitlements).
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/settings/NotificationsSection.tsx b/frontend/src/pages/settings/NotificationsSection.tsx
new file mode 100644
index 0000000..977c013
--- /dev/null
+++ b/frontend/src/pages/settings/NotificationsSection.tsx
@@ -0,0 +1,98 @@
+import { useState } from 'react'
+import { useQuery, useQueryClient } from '@tanstack/react-query'
+import { apiFetch } from '@/lib/api'
+import { Card, CardContent } from '@/components/ui/card'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import type { ServerConfig } from '@/types'
+
+export function NotificationsSection() {
+ const queryClient = useQueryClient()
+ const { data: config, isLoading } = useQuery({
+ queryKey: ['config'],
+ queryFn: () => apiFetch('/api/config'),
+ })
+
+ const [saving, setSaving] = useState(false)
+ const [webhookUrl, setWebhookUrl] = useState()
+ const [minSeverity, setMinSeverity] = useState()
+
+ const effectiveWebhook = webhookUrl ?? config?.slackWebhookUrl ?? ''
+ const effectiveMinSev = minSeverity ?? config?.notifyMinSeverity ?? 'high'
+
+ async function handleSave(e: React.FormEvent) {
+ e.preventDefault()
+ setSaving(true)
+ try {
+ await apiFetch('/api/config', {
+ method: 'PATCH',
+ body: JSON.stringify({
+ slackWebhookUrl: effectiveWebhook || null,
+ notifyMinSeverity: effectiveMinSev,
+ }),
+ })
+ await queryClient.invalidateQueries({ queryKey: ['config'] })
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ if (isLoading) return Loading...
+
+ return (
+
+
+
Notifications
+
Configure Slack alerts for findings.
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/settings/OrganizationSection.tsx b/frontend/src/pages/settings/OrganizationSection.tsx
new file mode 100644
index 0000000..572cc85
--- /dev/null
+++ b/frontend/src/pages/settings/OrganizationSection.tsx
@@ -0,0 +1,140 @@
+import { useState } from 'react'
+import { useQuery, useQueryClient } from '@tanstack/react-query'
+import { useAuth } from '@/hooks/useAuth'
+import { apiFetch } from '@/lib/api'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import { Badge } from '@/components/ui/badge'
+import { Avatar, AvatarFallback } from '@/components/ui/avatar'
+import { UserPlus } from 'lucide-react'
+
+interface OrgMember {
+ userId: string
+ email: string
+ name: string
+ role: string
+}
+
+export function OrganizationSection() {
+ const { user } = useAuth()
+ const queryClient = useQueryClient()
+ const orgId = user?.orgId
+
+ const { data: members = [], isLoading } = useQuery({
+ queryKey: ['org', orgId, 'members'],
+ queryFn: () => apiFetch(`/api/auth/organizations/${orgId}/members`),
+ enabled: !!orgId,
+ })
+
+ const [email, setEmail] = useState('')
+ const [role, setRole] = useState('member')
+ const [error, setError] = useState(null)
+ const [submitting, setSubmitting] = useState(false)
+
+ async function handleInvite(e: React.FormEvent) {
+ e.preventDefault()
+ if (!orgId || !email) return
+ setSubmitting(true)
+ setError(null)
+ try {
+ await apiFetch(`/api/auth/organizations/${orgId}/members`, {
+ method: 'POST',
+ body: JSON.stringify({ email, role }),
+ })
+ setEmail('')
+ await queryClient.invalidateQueries({ queryKey: ['org', orgId, 'members'] })
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Invite failed')
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ return (
+
+
+
Organization
+
Manage members and roles.
+
+
+
+
+ Members
+
+
+ {isLoading ? (
+ Loading...
+ ) : members.length === 0 ? (
+ No members yet.
+ ) : (
+ members.map(m => (
+
+
+
+ {m.name?.charAt(0).toUpperCase() ?? '?'}
+
+
+
+
{m.role}
+
+ ))
+ )}
+
+
+
+
+
+
+
+ Invite Member
+
+ Add a new member to your organization.
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/settings/ProfileSection.tsx b/frontend/src/pages/settings/ProfileSection.tsx
new file mode 100644
index 0000000..869392a
--- /dev/null
+++ b/frontend/src/pages/settings/ProfileSection.tsx
@@ -0,0 +1,60 @@
+import { useAuth } from '@/hooks/useAuth'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+import { Badge } from '@/components/ui/badge'
+import { Skeleton } from '@/components/ui/skeleton'
+import { Avatar, AvatarFallback } from '@/components/ui/avatar'
+
+export function ProfileSection() {
+ const { user, isLoading } = useAuth()
+
+ if (isLoading) {
+ return (
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
Profile
+
Your account information.
+
+
+
+
+
+
+
+ {user?.name?.charAt(0).toUpperCase() ?? '?'}
+
+
+
+ {user?.name}
+ {user?.email}
+
+
+
+
+
+ Role
+ {user?.role}
+
+
+ User ID
+ {user?.id}
+
+
+
+
+
+
+ Change Password
+ Password management coming in a future release.
+
+
+
+ )
+}
diff --git a/frontend/src/pages/settings/SettingsLayout.tsx b/frontend/src/pages/settings/SettingsLayout.tsx
new file mode 100644
index 0000000..1c7e681
--- /dev/null
+++ b/frontend/src/pages/settings/SettingsLayout.tsx
@@ -0,0 +1,46 @@
+import { NavLink, Outlet } from 'react-router-dom'
+import { User, Building, Key, Sliders, Bell, Palette, Shield } from 'lucide-react'
+import { cn } from '@/lib/utils'
+
+const navItems = [
+ { label: 'Profile', href: '/settings/profile', icon: User },
+ { label: 'Organization', href: '/settings/organization', icon: Building },
+ { label: 'API Keys', href: '/settings/api-keys', icon: Key },
+ { label: 'Exploration Defaults', href: '/settings/defaults', icon: Sliders },
+ { label: 'Notifications', href: '/settings/notifications', icon: Bell },
+ { label: 'Appearance', href: '/settings/appearance', icon: Palette },
+ { label: 'License', href: '/settings/license', icon: Shield },
+]
+
+export function SettingsLayout() {
+ return (
+
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index c31e839..e25acc9 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -88,6 +88,40 @@ export interface AnomalySummary {
browser?: BrowserType;
}
+export type FindingStatus = 'open' | 'investigating' | 'resolved' | 'closed';
+
+export interface Finding {
+ id: string;
+ sessionId: string;
+ type: string;
+ severity: Severity;
+ description: string;
+ status: FindingStatus;
+ browser?: BrowserType;
+ browserVersion?: string;
+ actionTraceLength: number;
+ actionTrace: Action[];
+ evidence: AnomalyEvidence;
+ aiEnrichment?: AIEnrichment | null;
+ createdAt: string;
+ resolvedAt?: string | null;
+}
+
+export interface FindingSummary {
+ id: string;
+ sessionId: string;
+ type: string;
+ severity: Severity;
+ description: string;
+ status: FindingStatus;
+ browser?: BrowserType;
+ actionTraceLength: number;
+ evidence: AnomalyEvidence;
+ aiEnrichment?: AIEnrichment | null;
+ createdAt: string;
+ resolvedAt?: string | null;
+}
+
export interface Session {
sessionId: string;
url: string;
diff --git a/src/api/router.ts b/src/api/router.ts
index 52cbb6d..ec34400 100644
--- a/src/api/router.ts
+++ b/src/api/router.ts
@@ -5,6 +5,7 @@ import { Router } from 'express';
import { createCrawlingRouter } from '../modules/crawling/infrastructure/http/CrawlingController';
import { createFindingsRouter } from '../modules/findings/infrastructure/http/FindingsController';
import { createFuzzingRouter } from '../modules/fuzzing/infrastructure/http/FuzzingController';
+import { createReportingRouter } from '../modules/reporting/infrastructure/http/ReportingController';
import { createAuthController } from '../modules/auth/infrastructure/http/AuthController';
import { createAuthMiddleware } from '../modules/auth/application/middleware/AuthMiddleware';
import { ServerDependencies } from './server';
@@ -64,6 +65,7 @@ export function createRouter(deps: ServerDependencies): Router {
router.use('/sessions', createCrawlingRouter(deps.crawlingDeps));
router.use('/findings', createFindingsRouter(deps.findingsDeps));
router.use('/fuzz', createFuzzingRouter(deps.fuzzingDeps));
+ router.use('/reports', createReportingRouter(deps.reportingDeps));
return router;
}
diff --git a/src/api/server.ts b/src/api/server.ts
index 75f0848..5dad985 100644
--- a/src/api/server.ts
+++ b/src/api/server.ts
@@ -18,6 +18,7 @@ import { createRouter } from './router';
import { CrawlingControllerDeps } from '../modules/crawling/infrastructure/http/CrawlingController';
import { FindingsControllerDeps } from '../modules/findings/infrastructure/http/FindingsController';
import { FuzzingControllerDeps } from '../modules/fuzzing/infrastructure/http/FuzzingController';
+import { ReportingControllerDeps } from '../modules/reporting/infrastructure/http/ReportingController';
import { AuthControllerDeps } from './router';
export interface ServerDependencies {
@@ -27,6 +28,7 @@ export interface ServerDependencies {
crawlingDeps: CrawlingControllerDeps;
findingsDeps: FindingsControllerDeps;
fuzzingDeps: FuzzingControllerDeps;
+ reportingDeps: ReportingControllerDeps;
authDeps: AuthControllerDeps;
}
diff --git a/src/db/migrations/005_reports_table.ts b/src/db/migrations/005_reports_table.ts
new file mode 100644
index 0000000..69df431
--- /dev/null
+++ b/src/db/migrations/005_reports_table.ts
@@ -0,0 +1,24 @@
+import { Kysely } from 'kysely';
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export async function up(db: Kysely): Promise {
+ await db.schema
+ .createTable('reports')
+ .ifNotExists()
+ .addColumn('id', 'text', (col) => col.primaryKey())
+ .addColumn('title', 'text', (col) => col.notNull())
+ .addColumn('format', 'text', (col) => col.notNull())
+ .addColumn('status', 'text', (col) => col.notNull().defaultTo('pending'))
+ .addColumn('filters_json', 'text', (col) => col.notNull().defaultTo('{}'))
+ .addColumn('file_path', 'text')
+ .addColumn('error_message', 'text')
+ .addColumn('total_findings', 'integer', (col) => col.notNull().defaultTo(0))
+ .addColumn('created_at', 'integer', (col) => col.notNull())
+ .addColumn('completed_at', 'integer')
+ .execute();
+}
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export async function down(db: Kysely): Promise {
+ await db.schema.dropTable('reports').ifExists().execute();
+}
diff --git a/src/jobs/workers/ReportWorker.ts b/src/jobs/workers/ReportWorker.ts
index eabaeae..c29390f 100644
--- a/src/jobs/workers/ReportWorker.ts
+++ b/src/jobs/workers/ReportWorker.ts
@@ -1,9 +1,14 @@
/**
* ReportWorker — handles 'report:generate' jobs.
- * Generates reports in the background (full implementation in Phase 15).
+ * Generates HTML, JSON, or PDF reports in the background.
*/
import { JobHandler } from '../JobQueue';
import { Logger } from '../../shared/infrastructure/Logger';
+import { IReportRepository } from '../../modules/reporting/domain/ports/IReportRepository';
+import { IFindingRepository } from '../../modules/findings/domain/ports/IFindingRepository';
+import { HTMLReportGenerator } from '../../modules/reporting/infrastructure/generators/HTMLReportGenerator';
+import { JSONReportGenerator } from '../../modules/reporting/infrastructure/generators/JSONReportGenerator';
+import { PDFReportGenerator } from '../../modules/reporting/infrastructure/generators/PDFReportGenerator';
export const REPORT_JOB_TYPE = 'report:generate';
@@ -25,16 +30,51 @@ export interface ReportJobResult {
export function createReportJobHandler(deps: {
logger: Logger;
+ reportRepository: IReportRepository;
+ findingRepository: IFindingRepository;
}): JobHandler {
+ const htmlGen = new HTMLReportGenerator();
+ const jsonGen = new JSONReportGenerator();
+ const pdfGen = new PDFReportGenerator();
+
return async (payload: ReportJobPayload): Promise => {
const log = deps.logger.child({ jobType: REPORT_JOB_TYPE, reportId: payload.reportId });
log.info({ format: payload.format }, 'Report generation job executing');
- // Full implementation in Phase 15 (Reporting Module)
- // For now, return a placeholder result
- const filePath = `./reports/${payload.reportId}.${payload.format}`;
- log.info({ filePath }, 'Report job complete');
+ const report = await deps.reportRepository.findById(payload.reportId);
+ if (!report) {
+ throw new Error(`Report not found: ${payload.reportId}`);
+ }
+ report.markGenerating();
+ await deps.reportRepository.update(report);
+
+ // Load findings with filters from report
+ const findings = await deps.findingRepository.findAll({
+ sessionId: report.filters.sessionId,
+ severity: report.filters.severity,
+ });
+
+ let filePath: string;
+ try {
+ if (payload.format === 'pdf') {
+ filePath = await pdfGen.generate(report, findings);
+ } else if (payload.format === 'json') {
+ filePath = await jsonGen.generate(report, findings);
+ } else {
+ filePath = await htmlGen.generate(report, findings);
+ }
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err);
+ report.markFailed(msg);
+ await deps.reportRepository.update(report);
+ throw err;
+ }
+
+ report.markReady(filePath, findings.length);
+ await deps.reportRepository.update(report);
+
+ log.info({ filePath, totalFindings: findings.length }, 'Report job complete');
return { reportId: payload.reportId, filePath };
};
}
diff --git a/src/main.ts b/src/main.ts
index fc37e75..d4d7cad 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -51,6 +51,10 @@ import { GetUserQuery } from './modules/auth/application/queries/GetUserQuery';
import { ListOrgMembersQuery } from './modules/auth/application/queries/ListOrgMembersQuery';
import { hashPassword, verifyPassword } from './modules/auth/infrastructure/auth/PasswordService';
+// Reporting module
+import { KyselyReportRepository } from './modules/reporting/infrastructure/repositories/KyselyReportRepository';
+import { GenerateReportCommand } from './modules/reporting/application/commands/GenerateReportCommand';
+
// Job queue
import { SQLiteJobQueue } from './jobs/SQLiteJobQueue';
import { createExplorationJobHandler, EXPLORATION_JOB_TYPE } from './jobs/workers/ExplorationWorker';
@@ -80,6 +84,7 @@ async function bootstrap(): Promise {
const sessionRepo = new KyselyCrawlSessionRepository(db);
const stateRepo = new KyselyStateRepository(db);
const findingRepo = new KyselyFindingRepository(db);
+ const reportRepo = new KyselyReportRepository(db);
const fuzzRepo = new InMemoryFuzzSessionRepository();
// Suppress unused warning for stateRepo — used by crawling infrastructure
@@ -125,7 +130,19 @@ async function bootstrap(): Promise {
const getUserQuery = new GetUserQuery(userRepo);
const listOrgMembersQuery = new ListOrgMembersQuery(orgRepo, userRepo);
- // 11. HTTP server
+ // 11. Reporting use cases
+ const generateReport = new GenerateReportCommand(reportRepo, eventBus);
+
+ // 12. Job queue (created before HTTP server so it can be injected)
+ const jobQueue = new SQLiteJobQueue(db, logger, config.jobs.pollIntervalMs);
+ jobQueue.registerHandler(
+ EXPLORATION_JOB_TYPE,
+ createExplorationJobHandler({ sessionRepo, eventBus, logger }),
+ );
+ jobQueue.registerHandler(REPORT_JOB_TYPE, createReportJobHandler({ logger, reportRepository: reportRepo, findingRepository: findingRepo }));
+ jobQueue.start();
+
+ // 13. HTTP server
const app = createServer({
config,
logger,
@@ -133,6 +150,7 @@ async function bootstrap(): Promise {
crawlingDeps: { startCrawl, stopCrawl, getSession, listSessions },
findingsDeps: { getFinding, listFindings, findingStats, resolveFinding, enrichFinding },
fuzzingDeps: { runFuzz, repository: fuzzRepo },
+ reportingDeps: { generateReport, reportRepository: reportRepo, jobQueue },
authDeps: {
registerCommand,
loginCommand,
@@ -149,15 +167,6 @@ async function bootstrap(): Promise {
const httpServer = http.createServer(app);
- // 11. Job queue
- const jobQueue = new SQLiteJobQueue(db, logger, config.jobs.pollIntervalMs);
- jobQueue.registerHandler(
- EXPLORATION_JOB_TYPE,
- createExplorationJobHandler({ sessionRepo, eventBus, logger }),
- );
- jobQueue.registerHandler(REPORT_JOB_TYPE, createReportJobHandler({ logger }));
- jobQueue.start();
-
// 12. Socket.io + gateway
const io = new SocketIOServer(httpServer, {
cors: { origin: config.cors.origin, credentials: true },
diff --git a/src/modules/findings/infrastructure/http/FindingsController.ts b/src/modules/findings/infrastructure/http/FindingsController.ts
index 7bb881a..d76815b 100644
--- a/src/modules/findings/infrastructure/http/FindingsController.ts
+++ b/src/modules/findings/infrastructure/http/FindingsController.ts
@@ -50,7 +50,7 @@ export function createFindingsRouter(deps: FindingsControllerDeps): Router {
res.json(result.value);
});
- // GET /api/findings/:id — finding detail
+ // GET /api/findings/:id — finding detail (includes actionTrace)
router.get('/:id', async (req: Request, res: Response) => {
const findingId = req.params['id'] as string;
const result = await deps.getFinding.execute({ findingId });
@@ -58,7 +58,8 @@ export function createFindingsRouter(deps: FindingsControllerDeps): Router {
res.status(404).json({ error: result.error });
return;
}
- res.json(toDTO(result.value));
+ const f = result.value;
+ res.json({ ...toDTO(f), actionTrace: f.actionTrace });
});
// PATCH /api/findings/:id/status — update status
diff --git a/src/modules/reporting/application/commands/GenerateReportCommand.ts b/src/modules/reporting/application/commands/GenerateReportCommand.ts
new file mode 100644
index 0000000..33911c3
--- /dev/null
+++ b/src/modules/reporting/application/commands/GenerateReportCommand.ts
@@ -0,0 +1,50 @@
+import { UseCase } from '../../../../shared/application/UseCase';
+import { Result, Ok, Err } from '../../../../shared/domain/Result';
+import { EventBus } from '../../../../shared/application/EventBus';
+import { IReportRepository } from '../../domain/ports/IReportRepository';
+import { Report, ReportFilters } from '../../domain/entities/Report';
+import { ReportFormat } from '../../domain/value-objects/ReportFormat';
+
+export interface GenerateReportRequest {
+ title: string;
+ format: 'html' | 'json' | 'pdf';
+ filters?: ReportFilters;
+}
+
+export interface GenerateReportResponse {
+ reportId: string;
+ status: string;
+}
+
+export class GenerateReportCommand
+ implements UseCase
+{
+ constructor(
+ private readonly reportRepository: IReportRepository,
+ private readonly eventBus: EventBus
+ ) {}
+
+ async execute(request: GenerateReportRequest): Promise> {
+ let format: ReportFormat;
+ try {
+ format = ReportFormat.fromString(request.format);
+ } catch {
+ return Err(`Invalid format: ${request.format}`);
+ }
+
+ const report = Report.create({
+ title: request.title,
+ format,
+ filters: request.filters ?? {},
+ });
+
+ await this.reportRepository.save(report);
+
+ const events = report.clearEvents();
+ for (const event of events) {
+ await this.eventBus.publish(event);
+ }
+
+ return Ok({ reportId: report.id.toString(), status: report.status.value });
+ }
+}
diff --git a/src/modules/reporting/domain/entities/Report.ts b/src/modules/reporting/domain/entities/Report.ts
new file mode 100644
index 0000000..463b998
--- /dev/null
+++ b/src/modules/reporting/domain/entities/Report.ts
@@ -0,0 +1,90 @@
+import { AggregateRoot } from '../../../../shared/domain/AggregateRoot';
+import { UniqueId } from '../../../../shared/domain/UniqueId';
+import { ReportFormat } from '../value-objects/ReportFormat';
+import { ReportStatus } from '../value-objects/ReportStatus';
+import { ReportRequested } from '../events/ReportRequested';
+import { ReportGenerated } from '../events/ReportGenerated';
+import { ReportFailed } from '../events/ReportFailed';
+
+export interface ReportFilters {
+ sessionId?: string;
+ startDate?: Date;
+ endDate?: Date;
+ severity?: string;
+}
+
+export interface ReportProps {
+ title: string;
+ format: ReportFormat;
+ status: ReportStatus;
+ filters: ReportFilters;
+ filePath?: string;
+ errorMessage?: string;
+ totalFindings: number;
+ createdAt: Date;
+ completedAt?: Date;
+}
+
+export class Report extends AggregateRoot {
+ static create(props: { title: string; format: ReportFormat; filters: ReportFilters }, id?: UniqueId): Report {
+ const reportId = id ?? UniqueId.create();
+ const report = new Report(
+ {
+ ...props,
+ status: ReportStatus.pending(),
+ totalFindings: 0,
+ createdAt: new Date(),
+ },
+ reportId
+ );
+ report.addDomainEvent(
+ new ReportRequested(reportId.toString(), {
+ title: props.title,
+ format: props.format.value,
+ filters: props.filters as Record,
+ })
+ );
+ return report;
+ }
+
+ static reconstitute(props: ReportProps, id: UniqueId): Report {
+ return new Report(props, id);
+ }
+
+ get title(): string { return this.props.title; }
+ get format(): ReportFormat { return this.props.format; }
+ get status(): ReportStatus { return this.props.status; }
+ get filters(): ReportFilters { return this.props.filters; }
+ get filePath(): string | undefined { return this.props.filePath; }
+ get errorMessage(): string | undefined { return this.props.errorMessage; }
+ get totalFindings(): number { return this.props.totalFindings; }
+ get createdAt(): Date { return this.props.createdAt; }
+ get completedAt(): Date | undefined { return this.props.completedAt; }
+
+ markGenerating(): void {
+ this.props.status = ReportStatus.generating();
+ }
+
+ markReady(filePath: string, totalFindings: number): void {
+ this.props.status = ReportStatus.ready();
+ this.props.filePath = filePath;
+ this.props.totalFindings = totalFindings;
+ this.props.completedAt = new Date();
+ this.addDomainEvent(
+ new ReportGenerated(this.id.toString(), {
+ filePath,
+ totalFindings,
+ format: this.props.format.value,
+ })
+ );
+ }
+
+ markFailed(errorMessage: string): void {
+ this.props.status = ReportStatus.failed();
+ this.props.errorMessage = errorMessage;
+ this.props.completedAt = new Date();
+ this.addDomainEvent(
+ new ReportFailed(this.id.toString(), { errorMessage })
+ );
+ }
+}
diff --git a/src/modules/reporting/domain/events/ReportFailed.ts b/src/modules/reporting/domain/events/ReportFailed.ts
new file mode 100644
index 0000000..07b3ba4
--- /dev/null
+++ b/src/modules/reporting/domain/events/ReportFailed.ts
@@ -0,0 +1,9 @@
+import { randomUUID } from 'crypto';
+import { DomainEvent } from '../../../../shared/domain/DomainEvent';
+
+export class ReportFailed implements DomainEvent {
+ readonly eventId = randomUUID();
+ readonly eventName = 'reporting.report_failed';
+ readonly occurredOn = new Date();
+ constructor(readonly aggregateId: string, readonly payload: Record) {}
+}
diff --git a/src/modules/reporting/domain/events/ReportGenerated.ts b/src/modules/reporting/domain/events/ReportGenerated.ts
new file mode 100644
index 0000000..05c3168
--- /dev/null
+++ b/src/modules/reporting/domain/events/ReportGenerated.ts
@@ -0,0 +1,9 @@
+import { randomUUID } from 'crypto';
+import { DomainEvent } from '../../../../shared/domain/DomainEvent';
+
+export class ReportGenerated implements DomainEvent {
+ readonly eventId = randomUUID();
+ readonly eventName = 'reporting.report_generated';
+ readonly occurredOn = new Date();
+ constructor(readonly aggregateId: string, readonly payload: Record) {}
+}
diff --git a/src/modules/reporting/domain/events/ReportRequested.ts b/src/modules/reporting/domain/events/ReportRequested.ts
new file mode 100644
index 0000000..bf1a81a
--- /dev/null
+++ b/src/modules/reporting/domain/events/ReportRequested.ts
@@ -0,0 +1,9 @@
+import { randomUUID } from 'crypto';
+import { DomainEvent } from '../../../../shared/domain/DomainEvent';
+
+export class ReportRequested implements DomainEvent {
+ readonly eventId = randomUUID();
+ readonly eventName = 'reporting.report_requested';
+ readonly occurredOn = new Date();
+ constructor(readonly aggregateId: string, readonly payload: Record) {}
+}
diff --git a/src/modules/reporting/domain/ports/IReportGenerator.ts b/src/modules/reporting/domain/ports/IReportGenerator.ts
new file mode 100644
index 0000000..6ee3539
--- /dev/null
+++ b/src/modules/reporting/domain/ports/IReportGenerator.ts
@@ -0,0 +1,6 @@
+import { Finding } from '../../../findings/domain/entities/Finding';
+import { Report } from '../entities/Report';
+
+export interface IReportGenerator {
+ generate(report: Report, findings: Finding[]): Promise;
+}
diff --git a/src/modules/reporting/domain/ports/IReportRepository.ts b/src/modules/reporting/domain/ports/IReportRepository.ts
new file mode 100644
index 0000000..ddc2d67
--- /dev/null
+++ b/src/modules/reporting/domain/ports/IReportRepository.ts
@@ -0,0 +1,8 @@
+import { Report } from '../entities/Report';
+
+export interface IReportRepository {
+ save(report: Report): Promise;
+ findById(id: string): Promise;
+ findAll(): Promise;
+ update(report: Report): Promise;
+}
diff --git a/src/modules/reporting/domain/value-objects/DateRange.ts b/src/modules/reporting/domain/value-objects/DateRange.ts
new file mode 100644
index 0000000..d356d65
--- /dev/null
+++ b/src/modules/reporting/domain/value-objects/DateRange.ts
@@ -0,0 +1,16 @@
+import { ValueObject } from '../../../../shared/domain/ValueObject';
+
+interface DateRangeProps {
+ startDate: Date;
+ endDate: Date;
+}
+
+export class DateRange extends ValueObject {
+ get startDate(): Date { return this.props.startDate; }
+ get endDate(): Date { return this.props.endDate; }
+
+ static create(startDate: Date, endDate: Date): DateRange {
+ if (startDate > endDate) throw new Error('startDate must be before endDate');
+ return new DateRange({ startDate, endDate });
+ }
+}
diff --git a/src/modules/reporting/domain/value-objects/ReportFormat.ts b/src/modules/reporting/domain/value-objects/ReportFormat.ts
new file mode 100644
index 0000000..f84081b
--- /dev/null
+++ b/src/modules/reporting/domain/value-objects/ReportFormat.ts
@@ -0,0 +1,20 @@
+import { ValueObject } from '../../../../shared/domain/ValueObject';
+
+interface ReportFormatProps {
+ value: 'html' | 'json' | 'pdf';
+}
+
+export class ReportFormat extends ValueObject {
+ get value(): 'html' | 'json' | 'pdf' { return this.props.value; }
+
+ static html(): ReportFormat { return new ReportFormat({ value: 'html' }); }
+ static json(): ReportFormat { return new ReportFormat({ value: 'json' }); }
+ static pdf(): ReportFormat { return new ReportFormat({ value: 'pdf' }); }
+
+ static fromString(s: string): ReportFormat {
+ if (s === 'html' || s === 'json' || s === 'pdf') {
+ return new ReportFormat({ value: s });
+ }
+ throw new Error(`Invalid report format: ${s}`);
+ }
+}
diff --git a/src/modules/reporting/domain/value-objects/ReportStatus.ts b/src/modules/reporting/domain/value-objects/ReportStatus.ts
new file mode 100644
index 0000000..4c86c07
--- /dev/null
+++ b/src/modules/reporting/domain/value-objects/ReportStatus.ts
@@ -0,0 +1,21 @@
+import { ValueObject } from '../../../../shared/domain/ValueObject';
+
+interface ReportStatusProps {
+ value: 'pending' | 'generating' | 'ready' | 'failed';
+}
+
+export class ReportStatus extends ValueObject {
+ get value(): 'pending' | 'generating' | 'ready' | 'failed' { return this.props.value; }
+
+ static pending(): ReportStatus { return new ReportStatus({ value: 'pending' }); }
+ static generating(): ReportStatus { return new ReportStatus({ value: 'generating' }); }
+ static ready(): ReportStatus { return new ReportStatus({ value: 'ready' }); }
+ static failed(): ReportStatus { return new ReportStatus({ value: 'failed' }); }
+
+ static fromString(s: string): ReportStatus {
+ if (s === 'pending' || s === 'generating' || s === 'ready' || s === 'failed') {
+ return new ReportStatus({ value: s });
+ }
+ throw new Error(`Invalid report status: ${s}`);
+ }
+}
diff --git a/src/modules/reporting/infrastructure/generators/HTMLReportGenerator.ts b/src/modules/reporting/infrastructure/generators/HTMLReportGenerator.ts
new file mode 100644
index 0000000..202a277
--- /dev/null
+++ b/src/modules/reporting/infrastructure/generators/HTMLReportGenerator.ts
@@ -0,0 +1,110 @@
+import * as fs from 'fs';
+import * as path from 'path';
+import { IReportGenerator } from '../../domain/ports/IReportGenerator';
+import { Report } from '../../domain/entities/Report';
+import { Finding } from '../../../findings/domain/entities/Finding';
+
+export class HTMLReportGenerator implements IReportGenerator {
+ async generate(report: Report, findings: Finding[]): Promise {
+ const outputDir = path.join(process.cwd(), 'reports', report.id.toString());
+ fs.mkdirSync(outputDir, { recursive: true });
+
+ const severityCounts: Record = { critical: 0, high: 0, medium: 0, low: 0 };
+ for (const f of findings) {
+ const sev = f.severity.value;
+ severityCounts[sev] = (severityCounts[sev] ?? 0) + 1;
+ }
+
+ const findingsHtml = findings.map(f => `
+
+
+
${escapeHtml(f.description)}
+
+ Session: ${f.sessionId} ·
+ ${new Date(f.createdAt).toLocaleString()}
+
+
+ `).join('\n');
+
+ const html = `
+
+
+
+
+ ${escapeHtml(report.title)}
+
+
+
+ ${escapeHtml(report.title)}
+
+ Generated by ABE · ${new Date().toLocaleString()}
+ ${report.filters.sessionId ? ` · Session: ${report.filters.sessionId}` : ''}
+
+
+
+
+
${findings.length}
+
Total
+
+
+
${severityCounts['critical'] ?? 0}
+
Critical
+
+
+
${severityCounts['high'] ?? 0}
+
High
+
+
+
${severityCounts['medium'] ?? 0}
+
Medium
+
+
+
${severityCounts['low'] ?? 0}
+
Low
+
+
+
+ Findings (${findings.length})
+ ${findings.length === 0 ? 'No findings match the selected filters.
' : findingsHtml}
+
+
+
+`;
+
+ const filePath = path.join(outputDir, `report.html`);
+ fs.writeFileSync(filePath, html, 'utf8');
+ return filePath;
+ }
+}
+
+function escapeHtml(s: string): string {
+ return s
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+}
diff --git a/src/modules/reporting/infrastructure/generators/JSONReportGenerator.ts b/src/modules/reporting/infrastructure/generators/JSONReportGenerator.ts
new file mode 100644
index 0000000..f2ea1e3
--- /dev/null
+++ b/src/modules/reporting/infrastructure/generators/JSONReportGenerator.ts
@@ -0,0 +1,59 @@
+import * as fs from 'fs';
+import * as path from 'path';
+import { IReportGenerator } from '../../domain/ports/IReportGenerator';
+import { Report } from '../../domain/entities/Report';
+import { Finding } from '../../../findings/domain/entities/Finding';
+
+export class JSONReportGenerator implements IReportGenerator {
+ async generate(report: Report, findings: Finding[]): Promise {
+ const outputDir = path.join(process.cwd(), 'reports', report.id.toString());
+ fs.mkdirSync(outputDir, { recursive: true });
+
+ const data = {
+ reportId: report.id.toString(),
+ title: report.title,
+ generatedAt: new Date().toISOString(),
+ filters: report.filters,
+ summary: {
+ total: findings.length,
+ bySeverity: buildSeverityCount(findings),
+ byStatus: buildStatusCount(findings),
+ },
+ findings: findings.map(f => ({
+ id: f.id.toString(),
+ sessionId: f.sessionId,
+ type: f.type.value,
+ severity: f.severity.value,
+ description: f.description,
+ status: f.status.value,
+ browser: f.browser,
+ createdAt: f.createdAt.toISOString(),
+ resolvedAt: f.resolvedAt?.toISOString() ?? null,
+ evidence: f.evidence.toJSON(),
+ actionTraceLength: f.actionTrace.length,
+ })),
+ };
+
+ const filePath = path.join(outputDir, 'report.json');
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
+ return filePath;
+ }
+}
+
+function buildSeverityCount(findings: Finding[]): Record {
+ const counts: Record = {};
+ for (const f of findings) {
+ const s = f.severity.value;
+ counts[s] = (counts[s] ?? 0) + 1;
+ }
+ return counts;
+}
+
+function buildStatusCount(findings: Finding[]): Record {
+ const counts: Record = {};
+ for (const f of findings) {
+ const s = f.status.value;
+ counts[s] = (counts[s] ?? 0) + 1;
+ }
+ return counts;
+}
diff --git a/src/modules/reporting/infrastructure/generators/PDFReportGenerator.ts b/src/modules/reporting/infrastructure/generators/PDFReportGenerator.ts
new file mode 100644
index 0000000..2ed59e2
--- /dev/null
+++ b/src/modules/reporting/infrastructure/generators/PDFReportGenerator.ts
@@ -0,0 +1,48 @@
+import * as path from 'path';
+import * as fs from 'fs';
+import { IReportGenerator } from '../../domain/ports/IReportGenerator';
+import { Report } from '../../domain/entities/Report';
+import { Finding } from '../../../findings/domain/entities/Finding';
+import { HTMLReportGenerator } from './HTMLReportGenerator';
+
+/**
+ * PDF report generator — uses Playwright to render the HTML report to PDF.
+ * Requires Playwright + Chromium to be installed.
+ */
+export class PDFReportGenerator implements IReportGenerator {
+ private readonly htmlGenerator = new HTMLReportGenerator();
+
+ async generate(report: Report, findings: Finding[]): Promise {
+ // First generate the HTML version
+ const htmlPath = await this.htmlGenerator.generate(report, findings);
+ const outputDir = path.dirname(htmlPath);
+ const pdfPath = path.join(outputDir, 'report.pdf');
+
+ // Use Playwright to convert HTML to PDF
+ let chromium;
+ try {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const pw = require('playwright') as typeof import('playwright');
+ chromium = pw.chromium;
+ } catch {
+ throw new Error('Playwright not available — install playwright to generate PDF reports');
+ }
+
+ const browser = await chromium.launch({ headless: true });
+ try {
+ const page = await browser.newPage();
+ const htmlContent = fs.readFileSync(htmlPath, 'utf8');
+ await page.setContent(htmlContent, { waitUntil: 'networkidle' });
+ await page.pdf({
+ path: pdfPath,
+ format: 'A4',
+ printBackground: true,
+ margin: { top: '1cm', bottom: '1cm', left: '1cm', right: '1cm' },
+ });
+ } finally {
+ await browser.close();
+ }
+
+ return pdfPath;
+ }
+}
diff --git a/src/modules/reporting/infrastructure/http/ReportingController.ts b/src/modules/reporting/infrastructure/http/ReportingController.ts
new file mode 100644
index 0000000..e442be7
--- /dev/null
+++ b/src/modules/reporting/infrastructure/http/ReportingController.ts
@@ -0,0 +1,131 @@
+import { Router, Request, Response } from 'express';
+import * as path from 'path';
+import * as fs from 'fs';
+import { GenerateReportCommand } from '../../application/commands/GenerateReportCommand';
+import { IReportRepository } from '../../domain/ports/IReportRepository';
+import { IJobQueue } from '../../../../jobs/JobQueue';
+import { REPORT_JOB_TYPE, ReportJobPayload } from '../../../../jobs/workers/ReportWorker';
+
+export interface ReportingControllerDeps {
+ generateReport: GenerateReportCommand;
+ reportRepository: IReportRepository;
+ jobQueue: IJobQueue;
+}
+
+export function createReportingRouter(deps: ReportingControllerDeps): Router {
+ const router = Router();
+
+ // POST /api/reports — create and enqueue report
+ router.post('/', async (req: Request, res: Response) => {
+ const { title, format, filters } = req.body as {
+ title?: string;
+ format?: string;
+ filters?: {
+ sessionId?: string;
+ startDate?: string;
+ endDate?: string;
+ severity?: string;
+ };
+ };
+
+ if (!title || !format) {
+ res.status(400).json({ error: 'title and format are required' });
+ return;
+ }
+
+ const result = await deps.generateReport.execute({
+ title,
+ format: format as 'html' | 'json' | 'pdf',
+ filters: filters
+ ? {
+ sessionId: filters.sessionId,
+ startDate: filters.startDate ? new Date(filters.startDate) : undefined,
+ endDate: filters.endDate ? new Date(filters.endDate) : undefined,
+ severity: filters.severity,
+ }
+ : undefined,
+ });
+
+ if (!result.ok) {
+ res.status(400).json({ error: result.error });
+ return;
+ }
+
+ // Enqueue background job
+ await deps.jobQueue.enqueue(REPORT_JOB_TYPE, {
+ reportId: result.value.reportId,
+ format: format as 'html' | 'json' | 'pdf',
+ filters: filters as ReportJobPayload['filters'],
+ });
+
+ res.status(201).json(result.value);
+ });
+
+ // GET /api/reports — list all reports
+ router.get('/', async (_req: Request, res: Response) => {
+ const reports = await deps.reportRepository.findAll();
+ res.json(
+ reports.map(r => ({
+ id: r.id.toString(),
+ title: r.title,
+ format: r.format.value,
+ status: r.status.value,
+ totalFindings: r.totalFindings,
+ createdAt: r.createdAt.toISOString(),
+ completedAt: r.completedAt?.toISOString() ?? null,
+ }))
+ );
+ });
+
+ // GET /api/reports/:id — report detail
+ router.get('/:id', async (req: Request, res: Response) => {
+ const report = await deps.reportRepository.findById(req.params['id'] as string);
+ if (!report) {
+ res.status(404).json({ error: 'Report not found' });
+ return;
+ }
+ res.json({
+ id: report.id.toString(),
+ title: report.title,
+ format: report.format.value,
+ status: report.status.value,
+ filters: report.filters,
+ totalFindings: report.totalFindings,
+ errorMessage: report.errorMessage,
+ createdAt: report.createdAt.toISOString(),
+ completedAt: report.completedAt?.toISOString() ?? null,
+ });
+ });
+
+ // GET /api/reports/:id/download — download the generated file
+ router.get('/:id/download', async (req: Request, res: Response) => {
+ const report = await deps.reportRepository.findById(req.params['id'] as string);
+ if (!report) {
+ res.status(404).json({ error: 'Report not found' });
+ return;
+ }
+ if (report.status.value !== 'ready' || !report.filePath) {
+ res.status(409).json({ error: 'Report is not ready yet', status: report.status.value });
+ return;
+ }
+ if (!fs.existsSync(report.filePath)) {
+ res.status(410).json({ error: 'Report file no longer exists' });
+ return;
+ }
+
+ const ext = path.extname(report.filePath);
+ const contentTypes: Record = {
+ '.html': 'text/html',
+ '.json': 'application/json',
+ '.pdf': 'application/pdf',
+ };
+ const contentType = contentTypes[ext] ?? 'application/octet-stream';
+ const filename = `report-${report.id.toString()}${ext}`;
+
+ res.setHeader('Content-Type', contentType);
+ res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
+ fs.createReadStream(report.filePath).pipe(res);
+ });
+
+ return router;
+}
diff --git a/src/modules/reporting/infrastructure/repositories/KyselyReportRepository.ts b/src/modules/reporting/infrastructure/repositories/KyselyReportRepository.ts
new file mode 100644
index 0000000..4e8df61
--- /dev/null
+++ b/src/modules/reporting/infrastructure/repositories/KyselyReportRepository.ts
@@ -0,0 +1,84 @@
+import { Kysely } from 'kysely';
+import { Database, ReportTable } from '../../../../shared/infrastructure/DatabaseConnection';
+import { IReportRepository } from '../../domain/ports/IReportRepository';
+import { Report, ReportProps, ReportFilters } from '../../domain/entities/Report';
+import { UniqueId } from '../../../../shared/domain/UniqueId';
+import { ReportFormat } from '../../domain/value-objects/ReportFormat';
+import { ReportStatus } from '../../domain/value-objects/ReportStatus';
+
+export class KyselyReportRepository implements IReportRepository {
+ constructor(private readonly db: Kysely) {}
+
+ async save(report: Report): Promise {
+ const row: ReportTable = {
+ id: report.id.toString(),
+ title: report.title,
+ format: report.format.value,
+ status: report.status.value,
+ filters_json: JSON.stringify(report.filters),
+ file_path: report.filePath ?? null,
+ error_message: report.errorMessage ?? null,
+ total_findings: report.totalFindings,
+ created_at: report.createdAt.getTime(),
+ completed_at: report.completedAt ? report.completedAt.getTime() : null,
+ };
+ await this.db.insertInto('reports').values(row).execute();
+ }
+
+ async findById(id: string): Promise {
+ const row = await this.db
+ .selectFrom('reports')
+ .selectAll()
+ .where('id', '=', id)
+ .executeTakeFirst();
+ return row ? this.toDomain(row) : undefined;
+ }
+
+ async findAll(): Promise {
+ const rows = await this.db
+ .selectFrom('reports')
+ .selectAll()
+ .orderBy('created_at', 'desc')
+ .execute();
+ return rows.map(r => this.toDomain(r));
+ }
+
+ async update(report: Report): Promise {
+ await this.db
+ .updateTable('reports')
+ .set({
+ status: report.status.value,
+ file_path: report.filePath ?? null,
+ error_message: report.errorMessage ?? null,
+ total_findings: report.totalFindings,
+ completed_at: report.completedAt ? report.completedAt.getTime() : null,
+ })
+ .where('id', '=', report.id.toString())
+ .execute();
+ }
+
+ private toDomain(row: ReportTable): Report {
+ const filters = this.parseJson(row.filters_json, {});
+ const props: ReportProps = {
+ title: row.title,
+ format: ReportFormat.fromString(row.format),
+ status: ReportStatus.fromString(row.status),
+ filters: {
+ sessionId: filters.sessionId,
+ startDate: filters.startDate ? new Date(filters.startDate) : undefined,
+ endDate: filters.endDate ? new Date(filters.endDate) : undefined,
+ severity: filters.severity,
+ },
+ filePath: row.file_path ?? undefined,
+ errorMessage: row.error_message ?? undefined,
+ totalFindings: row.total_findings,
+ createdAt: new Date(row.created_at),
+ completedAt: row.completed_at ? new Date(row.completed_at) : undefined,
+ };
+ return Report.reconstitute(props, UniqueId.from(row.id));
+ }
+
+ private parseJson(json: string, fallback: T): T {
+ try { return JSON.parse(json) as T; } catch { return fallback; }
+ }
+}
diff --git a/src/shared/infrastructure/DatabaseConnection.ts b/src/shared/infrastructure/DatabaseConnection.ts
index 0d6b152..5aa0aac 100644
--- a/src/shared/infrastructure/DatabaseConnection.ts
+++ b/src/shared/infrastructure/DatabaseConnection.ts
@@ -201,6 +201,19 @@ export interface AuthSessionTable {
created_at: number;
}
+export interface ReportTable {
+ id: string;
+ title: string;
+ format: string;
+ status: string;
+ filters_json: string;
+ file_path: string | null;
+ error_message: string | null;
+ total_findings: number;
+ created_at: number;
+ completed_at: number | null;
+}
+
export interface Database {
sessions: SessionTable;
states: StateTable;
@@ -218,6 +231,7 @@ export interface Database {
org_members: OrgMemberTable;
api_keys: ApiKeyTable;
auth_sessions: AuthSessionTable;
+ reports: ReportTable;
}
export function createDatabase(config: { driver: string; path: string; url?: string }): Kysely {
diff --git a/tests/modules/reporting.test.ts b/tests/modules/reporting.test.ts
new file mode 100644
index 0000000..f9072a6
--- /dev/null
+++ b/tests/modules/reporting.test.ts
@@ -0,0 +1,198 @@
+import { Report } from '../../src/modules/reporting/domain/entities/Report';
+import { ReportFormat } from '../../src/modules/reporting/domain/value-objects/ReportFormat';
+import { ReportStatus } from '../../src/modules/reporting/domain/value-objects/ReportStatus';
+import { GenerateReportCommand } from '../../src/modules/reporting/application/commands/GenerateReportCommand';
+import { IReportRepository } from '../../src/modules/reporting/domain/ports/IReportRepository';
+import { EventBus } from '../../src/shared/application/EventBus';
+import { DomainEvent } from '../../src/shared/domain/DomainEvent';
+import { EventHandler } from '../../src/shared/application/EventHandler';
+
+// ─── Mock Repository ──────────────────────────────────────────────────────────
+
+class InMemoryReportRepository implements IReportRepository {
+ private store = new Map();
+
+ async save(report: Report): Promise {
+ this.store.set(report.id.toString(), report);
+ }
+
+ async findById(id: string): Promise {
+ return this.store.get(id);
+ }
+
+ async findAll(): Promise {
+ return Array.from(this.store.values());
+ }
+
+ async update(report: Report): Promise {
+ this.store.set(report.id.toString(), report);
+ }
+}
+
+// ─── Mock EventBus ────────────────────────────────────────────────────────────
+
+class MockEventBus implements EventBus {
+ published: DomainEvent[] = [];
+
+ async publish(event: DomainEvent): Promise {
+ this.published.push(event);
+ }
+
+ subscribe(_name: string, _handler: EventHandler): void {
+ // no-op
+ }
+}
+
+// ─── Report domain tests ───────────────────────────────────────────────────────
+
+describe('ReportFormat', () => {
+ it('parses valid formats', () => {
+ expect(ReportFormat.fromString('html').value).toBe('html');
+ expect(ReportFormat.fromString('json').value).toBe('json');
+ expect(ReportFormat.fromString('pdf').value).toBe('pdf');
+ });
+
+ it('throws on invalid format', () => {
+ expect(() => ReportFormat.fromString('xml')).toThrow();
+ });
+});
+
+describe('ReportStatus', () => {
+ it('creates statuses', () => {
+ expect(ReportStatus.pending().value).toBe('pending');
+ expect(ReportStatus.generating().value).toBe('generating');
+ expect(ReportStatus.ready().value).toBe('ready');
+ expect(ReportStatus.failed().value).toBe('failed');
+ });
+
+ it('parses from string', () => {
+ expect(ReportStatus.fromString('ready').value).toBe('ready');
+ });
+
+ it('throws on unknown status', () => {
+ expect(() => ReportStatus.fromString('unknown')).toThrow();
+ });
+});
+
+describe('Report aggregate', () => {
+ it('creates a report with pending status and emits ReportRequested event', () => {
+ const report = Report.create({
+ title: 'Test Report',
+ format: ReportFormat.fromString('html'),
+ filters: { severity: 'high' },
+ });
+
+ expect(report.title).toBe('Test Report');
+ expect(report.status.value).toBe('pending');
+ expect(report.totalFindings).toBe(0);
+
+ const events = report.clearEvents();
+ expect(events).toHaveLength(1);
+ expect(events[0]!.eventName).toBe('reporting.report_requested');
+ });
+
+ it('marks report as generating', () => {
+ const report = Report.create({
+ title: 'T',
+ format: ReportFormat.fromString('json'),
+ filters: {},
+ });
+ report.clearEvents();
+
+ report.markGenerating();
+ expect(report.status.value).toBe('generating');
+ });
+
+ it('marks report as ready and emits ReportGenerated', () => {
+ const report = Report.create({
+ title: 'T',
+ format: ReportFormat.fromString('json'),
+ filters: {},
+ });
+ report.clearEvents();
+
+ report.markReady('/reports/123/report.json', 5);
+ expect(report.status.value).toBe('ready');
+ expect(report.filePath).toBe('/reports/123/report.json');
+ expect(report.totalFindings).toBe(5);
+ expect(report.completedAt).toBeInstanceOf(Date);
+
+ const events = report.clearEvents();
+ expect(events[0]!.eventName).toBe('reporting.report_generated');
+ });
+
+ it('marks report as failed and emits ReportFailed', () => {
+ const report = Report.create({
+ title: 'T',
+ format: ReportFormat.fromString('pdf'),
+ filters: {},
+ });
+ report.clearEvents();
+
+ report.markFailed('Playwright error');
+ expect(report.status.value).toBe('failed');
+ expect(report.errorMessage).toBe('Playwright error');
+
+ const events = report.clearEvents();
+ expect(events[0]!.eventName).toBe('reporting.report_failed');
+ });
+});
+
+// ─── GenerateReportCommand tests ───────────────────────────────────────────────
+
+describe('GenerateReportCommand', () => {
+ let repo: InMemoryReportRepository;
+ let eventBus: MockEventBus;
+ let cmd: GenerateReportCommand;
+
+ beforeEach(() => {
+ repo = new InMemoryReportRepository();
+ eventBus = new MockEventBus();
+ cmd = new GenerateReportCommand(repo, eventBus);
+ });
+
+ it('creates and persists a report, returns Ok with reportId', async () => {
+ const result = await cmd.execute({ title: 'My Report', format: 'html' });
+
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+
+ const { reportId, status } = result.value;
+ expect(status).toBe('pending');
+
+ const saved = await repo.findById(reportId);
+ expect(saved).toBeDefined();
+ expect(saved!.title).toBe('My Report');
+ expect(saved!.format.value).toBe('html');
+ });
+
+ it('publishes ReportRequested event after creation', async () => {
+ await cmd.execute({ title: 'Report', format: 'json' });
+
+ expect(eventBus.published).toHaveLength(1);
+ expect(eventBus.published[0]!.eventName).toBe('reporting.report_requested');
+ });
+
+ it('applies filters when provided', async () => {
+ const result = await cmd.execute({
+ title: 'Filtered',
+ format: 'pdf',
+ filters: { sessionId: 'abc-123', severity: 'critical' },
+ });
+
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+
+ const saved = await repo.findById(result.value.reportId);
+ expect(saved!.filters.sessionId).toBe('abc-123');
+ expect(saved!.filters.severity).toBe('critical');
+ });
+
+ it('returns Err for invalid format', async () => {
+ const result = await cmd.execute({ title: 'Bad', format: 'xml' as 'html' });
+
+ expect(result.ok).toBe(false);
+ if (result.ok) return;
+ expect(result.error).toContain('Invalid format');
+ });
+});