Files
pn-new-crm/src/lib/services/reports.service.ts

302 lines
9.1 KiB
TypeScript
Raw Normal View History

import { and, desc, eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { generatedReports } from '@/lib/db/schema/operations';
import { notifications } from '@/lib/db/schema/operations';
import { files } from '@/lib/db/schema/documents';
import { ports } from '@/lib/db/schema/ports';
import { generatePdf } from '@/lib/pdf/generate';
import { minioClient, getPresignedUrl, buildStoragePath } from '@/lib/minio/index';
import { emitToRoom } from '@/lib/socket/server';
import { getQueue } from '@/lib/queue';
import { env } from '@/lib/env';
import { logger } from '@/lib/logger';
import { NotFoundError } from '@/lib/errors';
import {
fetchPipelineData,
fetchRevenueData,
fetchActivityData,
fetchOccupancyData,
} from '@/lib/services/report-generators';
import {
pipelineReportTemplate,
buildPipelineInputs,
} from '@/lib/pdf/templates/reports/pipeline-report';
import {
revenueReportTemplate,
buildRevenueInputs,
} from '@/lib/pdf/templates/reports/revenue-report';
import {
activityReportTemplate,
buildActivityInputs,
} from '@/lib/pdf/templates/reports/activity-report';
import {
occupancyReportTemplate,
buildOccupancyInputs,
} from '@/lib/pdf/templates/reports/occupancy-report';
import type { RequestReportInput, ListReportsInput } from '@/lib/validators/reports';
// ─── Report Type Map ──────────────────────────────────────────────────────────
const REPORT_TYPE_MAP = {
pipeline: {
fetchData: fetchPipelineData,
template: pipelineReportTemplate,
buildInputs: buildPipelineInputs,
},
revenue: {
fetchData: fetchRevenueData,
template: revenueReportTemplate,
buildInputs: buildRevenueInputs,
},
activity: {
fetchData: fetchActivityData,
template: activityReportTemplate,
buildInputs: buildActivityInputs,
},
occupancy: {
fetchData: fetchOccupancyData,
template: occupancyReportTemplate,
buildInputs: buildOccupancyInputs,
},
} as const;
type ReportType = keyof typeof REPORT_TYPE_MAP;
// ─── requestReport ────────────────────────────────────────────────────────────
export async function requestReport(
portId: string,
userId: string,
data: RequestReportInput,
) {
const [report] = await db
.insert(generatedReports)
.values({
portId,
reportType: data.reportType,
name: data.name,
status: 'queued',
parameters: data.parameters ?? {},
requestedBy: userId,
})
.returning();
if (!report) {
throw new Error('Failed to create report record');
}
await getQueue('reports').add('generate-report', { reportJobId: report.id });
emitToRoom(`user:${userId}`, 'report:queued', {
reportId: report.id,
reportType: report.reportType,
name: report.name,
});
return report;
}
// ─── listReports ──────────────────────────────────────────────────────────────
export async function listReports(portId: string, query: ListReportsInput) {
const conditions = [eq(generatedReports.portId, portId)];
if (query.status) {
conditions.push(eq(generatedReports.status, query.status));
}
const offset = (query.page - 1) * query.limit;
const [rows, countResult] = await Promise.all([
db
.select()
.from(generatedReports)
.where(and(...conditions))
.orderBy(desc(generatedReports.createdAt))
.limit(query.limit)
.offset(offset),
db.$count(generatedReports, and(...conditions)),
]);
return {
data: rows,
total: Number(countResult),
};
}
// ─── getReport ────────────────────────────────────────────────────────────────
export async function getReport(reportId: string, portId: string) {
const report = await db.query.generatedReports.findFirst({
where: and(eq(generatedReports.id, reportId), eq(generatedReports.portId, portId)),
});
if (!report) {
throw new NotFoundError('Report');
}
return report;
}
// ─── getDownloadUrl ───────────────────────────────────────────────────────────
export async function getDownloadUrl(reportId: string, portId: string) {
const report = await db.query.generatedReports.findFirst({
where: and(eq(generatedReports.id, reportId), eq(generatedReports.portId, portId)),
});
if (!report) {
throw new NotFoundError('Report');
}
if (report.status !== 'ready' || !report.fileId) {
throw new Error('Report is not ready for download');
}
const file = await db.query.files.findFirst({
where: eq(files.id, report.fileId),
});
if (!file) {
throw new NotFoundError('File');
}
const url = await getPresignedUrl(file.storagePath);
return { url };
}
// ─── generateReport ───────────────────────────────────────────────────────────
export async function generateReport(reportJobId: string): Promise<void> {
// 1. Fetch the generatedReports record
const report = await db.query.generatedReports.findFirst({
where: eq(generatedReports.id, reportJobId),
});
if (!report) {
throw new Error(`Report job not found: ${reportJobId}`);
}
const { portId, reportType, name, parameters, requestedBy } = report;
try {
// 2. Update status = 'processing', startedAt = now
await db
.update(generatedReports)
.set({ status: 'processing', startedAt: new Date() })
.where(eq(generatedReports.id, reportJobId));
// 3. Look up REPORT_TYPE_MAP[reportType]
const typeKey = reportType as ReportType;
const config = REPORT_TYPE_MAP[typeKey];
if (!config) {
throw new Error(`Unknown report type: ${reportType}`);
}
const params = (parameters ?? {}) as Record<string, unknown>;
// 4. Fetch data
const data = await config.fetchData(portId, params);
// 5. Get port info for name in PDF
const port = await db.query.ports.findFirst({
where: eq(ports.id, portId),
});
const portName = port?.name ?? 'Port Nimara';
const portSlug = port?.slug ?? 'port';
// 6. Build inputs (pass portName)
const inputs = (config.buildInputs as (data: unknown, portName: string) => Record<string, string>[])(data, portName);
// 7. Generate PDF
const pdfBytes = await generatePdf(config.template, inputs);
// 8. Build storage path
const fileId = crypto.randomUUID();
const storagePath = buildStoragePath(portSlug, 'reports', reportJobId, fileId, 'pdf');
// 9. Upload PDF to MinIO
const buffer = Buffer.from(pdfBytes);
await minioClient.putObject(
env.MINIO_BUCKET,
storagePath,
buffer,
buffer.length,
{ 'Content-Type': 'application/pdf', 'report-type': reportType },
);
// 10. Insert into files table
const [fileRecord] = await db
.insert(files)
.values({
id: fileId,
portId,
filename: `${name.replace(/[^a-z0-9]/gi, '_').toLowerCase()}_${Date.now()}.pdf`,
originalName: `${name}.pdf`,
mimeType: 'application/pdf',
sizeBytes: String(buffer.length),
storagePath,
storageBucket: env.MINIO_BUCKET,
category: 'misc',
uploadedBy: requestedBy,
})
.returning();
if (!fileRecord) {
throw new Error('Failed to insert file record');
}
// 11. Update generatedReports: status='ready', fileId, completedAt
await db
.update(generatedReports)
.set({
status: 'ready',
fileId: fileRecord.id,
completedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(generatedReports.id, reportJobId));
// 12. Emit report:ready socket event
emitToRoom(`user:${requestedBy}`, 'report:ready', {
reportId: reportJobId,
name,
});
// 13. Create notification for requestedBy user
await db.insert(notifications).values({
portId,
userId: requestedBy,
type: 'system_alert',
title: 'Report Ready',
description: `Your report "${name}" is ready to download.`,
entityType: 'report',
entityId: reportJobId,
});
logger.info({ reportJobId, reportType }, 'Report generated successfully');
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
logger.error({ reportJobId, err }, 'Report generation failed');
await db
.update(generatedReports)
.set({
status: 'failed',
errorMessage,
updatedAt: new Date(),
})
.where(eq(generatedReports.id, reportJobId));
emitToRoom(`user:${requestedBy}`, 'report:failed', {
reportId: reportJobId,
name,
error: errorMessage,
});
throw err;
}
}