Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions backend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,6 @@ dist/
*.log
coverage/
.DS_Store

# Generated PDF reports & other ephemeral artifacts
files/
2 changes: 2 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"express": "^4.21.2",
"helmet": "^8.0.0",
"jsonwebtoken": "^9.0.2",
"pdfkit": "^0.15.1",
"pino": "^9.6.0",
"pino-http": "^10.4.0",
"zod": "^3.24.1"
Expand All @@ -37,6 +38,7 @@
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.7",
"@types/node": "^22.10.2",
"@types/pdfkit": "^0.13.9",
"pino-pretty": "^13.0.0",
"prisma": "^6.1.0",
"tsx": "^4.19.2",
Expand Down
50 changes: 50 additions & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ model User {
// Audit linkage
emissionLogsSubmitted EmissionLog[] @relation("EmissionLogSubmitter")
noticesIssued ComplianceNotice[] @relation("NoticeIssuer")
reportsGenerated Report[] @relation("ReportsGenerated")

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
Expand Down Expand Up @@ -155,6 +156,7 @@ model Industry {
inspectorAssignments InspectorAssignment[]
emissionLogs EmissionLog[]
complianceNotices ComplianceNotice[]
reports Report[]

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
Expand Down Expand Up @@ -314,3 +316,51 @@ model ComplianceNotice {
@@index([issuedAt])
@@map("compliance_notices")
}



// -----------------------------------------------------------------------------
// REPORTS β€” auto-generated PDFs (and future CSVs) stored on local disk.
// -----------------------------------------------------------------------------

enum ReportType {
EMISSIONS_MONTHLY
EMISSIONS_QUARTERLY
COMPLIANCE_QUARTERLY
ANNUAL_REPORT
FACILITY_AUDIT
AI_INSIGHTS
CUSTOM
}

enum ReportFormat {
PDF
CSV
}

model Report {
id String @id @default(cuid())
type ReportType
format ReportFormat @default(PDF)
title String
periodStart DateTime?
periodEnd DateTime?

// Local file artifact: relative path under backend/files/ (e.g. "reports/abc.pdf").
storageKey String
sizeBytes Int
pageCount Int?

generatedById String?
generatedBy User? @relation("ReportsGenerated", fields: [generatedById], references: [id], onDelete: SetNull)

industryId String?
industry Industry? @relation(fields: [industryId], references: [id], onDelete: SetNull)

createdAt DateTime @default(now())

@@index([createdAt(sort: Desc)])
@@index([generatedById])
@@index([industryId, createdAt(sort: Desc)])
@@map("reports")
}
160 changes: 160 additions & 0 deletions backend/src/controllers/report.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import fs from 'node:fs';
import path from 'node:path';
import { z } from 'zod';
import { ReportType } from '@prisma/client';
import { asyncHandler } from '../utils/asyncHandler';
import { ApiError } from '../utils/ApiError';
import { prisma } from '../prisma/client';
import { generateReport, REPORTS_DIR } from '../services/reportService';

const generateSchema = z.object({
type: z.nativeEnum(ReportType).default(ReportType.EMISSIONS_MONTHLY),
periodStart: z.string().datetime().optional(),
periodEnd: z.string().datetime().optional(),
industryId: z.string().min(1).optional(),
});

/** POST /api/v1/reports/generate β€” create a new PDF report */
export const generate = asyncHandler(async (req, res) => {
const input = generateSchema.parse(req.body);

// INDUSTRY users can only generate for their own industry β€” silently force it.
let industryId = input.industryId;
if (req.user?.role === 'INDUSTRY') {
industryId = req.user.industryId ?? undefined;
}

const report = await generateReport({
type: input.type,
periodStart: input.periodStart ? new Date(input.periodStart) : undefined,
periodEnd: input.periodEnd ? new Date(input.periodEnd) : undefined,
industryId: industryId ?? null,
generatedById: req.user?.id ?? null,
});

res.status(201).json(toPublic(report));
});

/** GET /api/v1/reports β€” list recent reports (scoped per role) */
export const list = asyncHandler(async (req, res) => {
const where =
req.user?.role === 'INDUSTRY' && req.user.industryId
? { industryId: req.user.industryId }
: {};

const reports = await prisma.report.findMany({
where,
orderBy: { createdAt: 'desc' },
take: 100,
include: {
generatedBy: { select: { fullName: true, email: true } },
industry: { select: { name: true, sector: true } },
},
});

res.json({
count: reports.length,
reports: reports.map((r) => ({
...toPublic(r),
generatedByName: r.generatedBy?.fullName ?? null,
industryName: r.industry?.name ?? null,
})),
});
});

/** GET /api/v1/reports/:id/download β€” stream the PDF (auth + ownership) */
export const download = asyncHandler(async (req, res) => {
const { id } = req.params as { id: string };
const report = await prisma.report.findUnique({ where: { id } });
if (!report) throw ApiError.notFound('Report not found');

if (
req.user?.role === 'INDUSTRY' &&
report.industryId &&
report.industryId !== req.user.industryId
) {
throw ApiError.forbidden('Cannot access reports from other industries');
}

const filepath = path.join(REPORTS_DIR, path.basename(report.storageKey));
if (!fs.existsSync(filepath)) {
throw ApiError.notFound('Report file is missing on disk');
}

const safeName =
report.title.replace(/[^\w\-. ]+/g, '_').slice(0, 80) + '.pdf';

res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Length', String(report.sizeBytes));
res.setHeader(
'Content-Disposition',
`attachment; filename="${safeName}"`,
);

const stream = fs.createReadStream(filepath);
stream.on('error', () => {
if (!res.headersSent) {
res.status(500).json({
error: { code: 'STREAM_ERROR', message: 'Failed to stream report' },
});
} else {
res.end();
}
});
stream.pipe(res);
});

/** DELETE /api/v1/reports/:id β€” remove DB row + best-effort file cleanup */
export const remove = asyncHandler(async (req, res) => {
const { id } = req.params as { id: string };
const report = await prisma.report.findUnique({ where: { id } });
if (!report) throw ApiError.notFound('Report not found');

if (
req.user?.role === 'INDUSTRY' &&
report.industryId &&
report.industryId !== req.user.industryId
) {
throw ApiError.forbidden('Cannot delete reports from other industries');
}

await prisma.report.delete({ where: { id } });

// Best-effort file cleanup. Failure here doesn't bubble up β€” the row is gone.
fs.promises
.unlink(path.join(REPORTS_DIR, path.basename(report.storageKey)))
.catch(() => {});

res.status(204).send();
});

// -----------------------------------------------------------------------------

function toPublic(r: {
id: string;
type: ReportType;
format: string;
title: string;
periodStart: Date | null;
periodEnd: Date | null;
sizeBytes: number;
pageCount: number | null;
industryId: string | null;
generatedById: string | null;
createdAt: Date;
}) {
return {
id: r.id,
type: r.type,
format: r.format,
title: r.title,
periodStart: r.periodStart,
periodEnd: r.periodEnd,
sizeBytes: r.sizeBytes,
pageCount: r.pageCount,
industryId: r.industryId,
generatedById: r.generatedById,
generatedAt: r.createdAt,
downloadUrl: `/api/v1/reports/${r.id}/download`,
};
}
2 changes: 2 additions & 0 deletions backend/src/routes/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Router } from 'express';
import { healthRouter } from './health.routes';
import { authRouter } from './auth.routes';
import { reportRouter } from './report.routes';
import publicHeatmapRouter from './publicHeatmap';

/**
Expand All @@ -11,4 +12,5 @@ export const apiRouter = Router();

apiRouter.use('/health', healthRouter);
apiRouter.use('/auth', authRouter);
apiRouter.use('/reports', reportRouter);
apiRouter.use('/public', publicHeatmapRouter);
18 changes: 18 additions & 0 deletions backend/src/routes/report.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Router } from 'express';
import { requireAuth } from '../middleware/requireAuth';
import {
generate,
list,
download,
remove,
} from '../controllers/report.controller';

export const reportRouter = Router();

// All report endpoints require an authenticated user.
reportRouter.use(requireAuth);

reportRouter.post('/generate', generate);
reportRouter.get('/', list);
reportRouter.get('/:id/download', download);
reportRouter.delete('/:id', remove);
Loading