feat: implement security report generation backend (task 21)
- Add report-schedules DB schema table - Create reports tRPC router with getReports, generateReport, getReport, deleteReport, getScheduledReports, updateSchedule procedures - Create reports service with async report generation lifecycle - Create report generator (compileData, renderHTML, generatePDF, uploadPDF) - Add HTML templates for monthly-plus, annual-premium, weekly-digest - Add Valibot schemas for input validation - Wire router into root.ts and update DB schema exports/relations - Install puppeteer for HTML-to-PDF conversion - Write unit tests for router (11 tests) and service (12 tests)
This commit is contained in:
@@ -8,6 +8,7 @@ import { spamshieldRouter } from "./routers/spamshield";
|
||||
import { hometitleRouter } from "./routers/hometitle";
|
||||
import { removebrokersRouter } from "./routers/removebrokers";
|
||||
import { correlationRouter } from "./routers/correlation";
|
||||
import { reportsRouter } from "./routers/reports";
|
||||
import { createTRPCRouter } from "./utils";
|
||||
|
||||
export const appRouter = createTRPCRouter({
|
||||
@@ -21,6 +22,7 @@ export const appRouter = createTRPCRouter({
|
||||
hometitle: hometitleRouter,
|
||||
removebrokers: removebrokersRouter,
|
||||
correlation: correlationRouter,
|
||||
reports: reportsRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
190
web/src/server/api/routers/reports.test.ts
Normal file
190
web/src/server/api/routers/reports.test.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { initTRPC, TRPCError } from "@trpc/server";
|
||||
import { wrap } from "@typeschema/valibot";
|
||||
import {
|
||||
GenerateReportSchema,
|
||||
ReportFilterSchema,
|
||||
ReportDetailsSchema,
|
||||
DeleteReportSchema,
|
||||
UpdateScheduleSchema,
|
||||
} from "../schemas/reports";
|
||||
|
||||
vi.mock("~/server/services/reports.service", () => ({
|
||||
getReports: vi.fn(),
|
||||
generateReport: vi.fn(),
|
||||
getReport: vi.fn(),
|
||||
deleteReport: vi.fn(),
|
||||
getScheduledReports: vi.fn(),
|
||||
updateSchedule: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as reportsService from "~/server/services/reports.service";
|
||||
|
||||
const mockGetReports = vi.mocked(reportsService.getReports);
|
||||
const mockGenerateReport = vi.mocked(reportsService.generateReport);
|
||||
const mockGetReport = vi.mocked(reportsService.getReport);
|
||||
const mockDeleteReport = vi.mocked(reportsService.deleteReport);
|
||||
const mockGetScheduledReports = vi.mocked(reportsService.getScheduledReports);
|
||||
const mockUpdateSchedule = vi.mocked(reportsService.updateSchedule);
|
||||
|
||||
type User = {
|
||||
id: string; email: string; name: string | null; image: string | null;
|
||||
role: string; emailVerified: Date | null; deletedAt: Date | null;
|
||||
stripeCustomerId: string | null;
|
||||
createdAt: Date; updatedAt: Date;
|
||||
};
|
||||
type Ctx = { db: object; user: User | null; apiKey: string | null };
|
||||
|
||||
function createCaller(user: User | null) {
|
||||
const t = initTRPC.context<Ctx>().create();
|
||||
const isAuthed = t.middleware(({ ctx, next }) => {
|
||||
if (!ctx.user) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
return next({ ctx: { ...ctx, user: ctx.user } });
|
||||
});
|
||||
|
||||
const router = t.router({
|
||||
getReports: t.procedure.use(isAuthed)
|
||||
.input(wrap(ReportFilterSchema))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return mockGetReports(ctx.user.id, input);
|
||||
}),
|
||||
generateReport: t.procedure.use(isAuthed)
|
||||
.input(wrap(GenerateReportSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return mockGenerateReport(ctx.user.id, input.reportType, input.periodStart, input.periodEnd);
|
||||
}),
|
||||
getReport: t.procedure.use(isAuthed)
|
||||
.input(wrap(ReportDetailsSchema))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return mockGetReport(ctx.user.id, input.reportId);
|
||||
}),
|
||||
deleteReport: t.procedure.use(isAuthed)
|
||||
.input(wrap(DeleteReportSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return mockDeleteReport(ctx.user.id, input.reportId);
|
||||
}),
|
||||
getScheduledReports: t.procedure.use(isAuthed)
|
||||
.query(async ({ ctx }) => {
|
||||
return mockGetScheduledReports(ctx.user.id);
|
||||
}),
|
||||
updateSchedule: t.procedure.use(isAuthed)
|
||||
.input(wrap(UpdateScheduleSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return mockUpdateSchedule(ctx.user.id, input);
|
||||
}),
|
||||
});
|
||||
|
||||
const caller = t.createCallerFactory(router);
|
||||
return caller({ db: {} as never, user, apiKey: null });
|
||||
}
|
||||
|
||||
const baseUser: User = {
|
||||
id: "user-1", email: "a@b.com", name: "Test", image: null,
|
||||
role: "user", emailVerified: null, deletedAt: null,
|
||||
stripeCustomerId: null,
|
||||
createdAt: new Date(), updatedAt: new Date(),
|
||||
};
|
||||
|
||||
function makeUser(overrides: Partial<User> = {}): User {
|
||||
return { ...baseUser, ...overrides };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("reports.getReports", () => {
|
||||
it("returns paginated reports for authenticated user", async () => {
|
||||
const data = { items: [], total: 0, page: 1, limit: 20, totalPages: 0 };
|
||||
mockGetReports.mockResolvedValue(data);
|
||||
const api = createCaller(makeUser());
|
||||
const result = await api.getReports({ page: 1, limit: 20 });
|
||||
expect(result.total).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects unauthenticated", async () => {
|
||||
const api = createCaller(null);
|
||||
await expect(api.getReports({ page: 1, limit: 20 })).rejects.toThrow(TRPCError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reports.generateReport", () => {
|
||||
it("triggers report generation", async () => {
|
||||
mockGenerateReport.mockResolvedValue({ reportId: "r1" });
|
||||
const api = createCaller(makeUser());
|
||||
const result = await api.generateReport({ reportType: "MONTHLY_PLUS" });
|
||||
expect(result.reportId).toBe("r1");
|
||||
});
|
||||
|
||||
it("rejects invalid report type", async () => {
|
||||
const api = createCaller(makeUser());
|
||||
await expect(
|
||||
api.generateReport({ reportType: "INVALID" as never }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("passes optional period dates", async () => {
|
||||
mockGenerateReport.mockResolvedValue({ reportId: "r1" });
|
||||
const api = createCaller(makeUser());
|
||||
await api.generateReport({
|
||||
reportType: "WEEKLY_DIGEST",
|
||||
periodStart: "2025-01-01",
|
||||
periodEnd: "2025-01-07",
|
||||
});
|
||||
expect(mockGenerateReport).toHaveBeenCalledWith("user-1", "WEEKLY_DIGEST", "2025-01-01", "2025-01-07");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reports.getReport", () => {
|
||||
it("returns a single report", async () => {
|
||||
mockGetReport.mockResolvedValue({ id: "r1", title: "Test Report" } as never);
|
||||
const api = createCaller(makeUser());
|
||||
const result = await api.getReport({ reportId: "r1" });
|
||||
expect(result.id).toBe("r1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reports.deleteReport", () => {
|
||||
it("deletes a report", async () => {
|
||||
mockDeleteReport.mockResolvedValue({ deleted: true });
|
||||
const api = createCaller(makeUser());
|
||||
const result = await api.deleteReport({ reportId: "r1" });
|
||||
expect(result.deleted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reports.getScheduledReports", () => {
|
||||
it("returns scheduled reports for authenticated user", async () => {
|
||||
const schedules = [{ id: "s1", enabled: true, frequency: "weekly", reportType: "WEEKLY_DIGEST" }];
|
||||
mockGetScheduledReports.mockResolvedValue(schedules as never);
|
||||
const api = createCaller(makeUser());
|
||||
const result = await api.getScheduledReports();
|
||||
expect(result).toEqual(schedules);
|
||||
});
|
||||
|
||||
it("rejects unauthenticated", async () => {
|
||||
const api = createCaller(null);
|
||||
await expect(api.getScheduledReports()).rejects.toThrow(TRPCError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reports.updateSchedule", () => {
|
||||
it("updates schedule for authenticated user", async () => {
|
||||
const schedule = { id: "s1", enabled: true, frequency: "monthly", reportType: "MONTHLY_PLUS" };
|
||||
mockUpdateSchedule.mockResolvedValue(schedule as never);
|
||||
const api = createCaller(makeUser());
|
||||
const result = await api.updateSchedule({
|
||||
enabled: true,
|
||||
frequency: "monthly",
|
||||
reportType: "MONTHLY_PLUS",
|
||||
});
|
||||
expect(result.id).toBe("s1");
|
||||
});
|
||||
|
||||
it("rejects invalid frequency", async () => {
|
||||
const api = createCaller(makeUser());
|
||||
await expect(
|
||||
api.updateSchedule({ enabled: true, frequency: "daily" as never, reportType: "MONTHLY_PLUS" }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
46
web/src/server/api/routers/reports.ts
Normal file
46
web/src/server/api/routers/reports.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { wrap } from "@typeschema/valibot";
|
||||
import { createTRPCRouter, protectedProcedure } from "../utils";
|
||||
import {
|
||||
GenerateReportSchema,
|
||||
ReportFilterSchema,
|
||||
ReportDetailsSchema,
|
||||
DeleteReportSchema,
|
||||
UpdateScheduleSchema,
|
||||
} from "../schemas/reports";
|
||||
import * as reportsService from "~/server/services/reports.service";
|
||||
|
||||
export const reportsRouter = createTRPCRouter({
|
||||
getReports: protectedProcedure
|
||||
.input(wrap(ReportFilterSchema))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return reportsService.getReports(ctx.user.id, input);
|
||||
}),
|
||||
|
||||
generateReport: protectedProcedure
|
||||
.input(wrap(GenerateReportSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return reportsService.generateReport(ctx.user.id, input.reportType, input.periodStart, input.periodEnd);
|
||||
}),
|
||||
|
||||
getReport: protectedProcedure
|
||||
.input(wrap(ReportDetailsSchema))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return reportsService.getReport(ctx.user.id, input.reportId);
|
||||
}),
|
||||
|
||||
deleteReport: protectedProcedure
|
||||
.input(wrap(DeleteReportSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return reportsService.deleteReport(ctx.user.id, input.reportId);
|
||||
}),
|
||||
|
||||
getScheduledReports: protectedProcedure.query(async ({ ctx }) => {
|
||||
return reportsService.getScheduledReports(ctx.user.id);
|
||||
}),
|
||||
|
||||
updateSchedule: protectedProcedure
|
||||
.input(wrap(UpdateScheduleSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return reportsService.updateSchedule(ctx.user.id, input);
|
||||
}),
|
||||
});
|
||||
26
web/src/server/api/schemas/reports.ts
Normal file
26
web/src/server/api/schemas/reports.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { object, string, picklist, optional, number, boolean, minLength } from "valibot";
|
||||
|
||||
export const GenerateReportSchema = object({
|
||||
reportType: picklist(["MONTHLY_PLUS", "ANNUAL_PREMIUM", "WEEKLY_DIGEST"]),
|
||||
periodStart: optional(string()),
|
||||
periodEnd: optional(string()),
|
||||
});
|
||||
|
||||
export const ReportFilterSchema = object({
|
||||
page: optional(number(), 1),
|
||||
limit: optional(number(), 20),
|
||||
});
|
||||
|
||||
export const ReportDetailsSchema = object({
|
||||
reportId: string([minLength(1)]),
|
||||
});
|
||||
|
||||
export const DeleteReportSchema = object({
|
||||
reportId: string([minLength(1)]),
|
||||
});
|
||||
|
||||
export const UpdateScheduleSchema = object({
|
||||
enabled: boolean(),
|
||||
frequency: picklist(["weekly", "monthly", "quarterly"]),
|
||||
reportType: picklist(["MONTHLY_PLUS", "ANNUAL_PREMIUM", "WEEKLY_DIGEST"]),
|
||||
});
|
||||
@@ -13,4 +13,5 @@ export * from "./hometitle";
|
||||
export * from "./removebrokers";
|
||||
export * from "./invitation";
|
||||
export * from "./notifications";
|
||||
export * from "./report-schedules";
|
||||
export * from "./relations";
|
||||
|
||||
@@ -13,6 +13,7 @@ import { voiceEnrollments, voiceAnalyses, analysisJobs, analysisResults } from "
|
||||
import { spamFeedback, spamRules } from "./spamshield";
|
||||
import { normalizedAlerts, correlationGroups } from "./correlation";
|
||||
import { securityReports } from "./reports";
|
||||
import { reportSchedules } from "./report-schedules";
|
||||
import { propertyWatchlistItems, propertySnapshots, propertyChanges } from "./hometitle";
|
||||
import { infoBrokers, removalRequests, brokerListings } from "./removebrokers";
|
||||
|
||||
@@ -32,6 +33,7 @@ export const usersRelations = relations(users, ({ one, many }) => ({
|
||||
normalizedAlerts: many(normalizedAlerts),
|
||||
correlationGroups: many(correlationGroups),
|
||||
securityReports: many(securityReports),
|
||||
reportSchedules: many(reportSchedules),
|
||||
analysisJobs: many(analysisJobs),
|
||||
notificationPreferences: one(notificationPreferences),
|
||||
}));
|
||||
@@ -134,6 +136,10 @@ export const securityReportsRelations = relations(securityReports, ({ one }) =>
|
||||
user: one(users, { fields: [securityReports.userId], references: [users.id] }),
|
||||
}));
|
||||
|
||||
export const reportSchedulesRelations = relations(reportSchedules, ({ one }) => ({
|
||||
user: one(users, { fields: [reportSchedules.userId], references: [users.id] }),
|
||||
}));
|
||||
|
||||
export const propertyWatchlistItemsRelations = relations(propertyWatchlistItems, ({ one, many }) => ({
|
||||
subscription: one(subscriptions, { fields: [propertyWatchlistItems.subscriptionId], references: [subscriptions.id] }),
|
||||
snapshots: many(propertySnapshots),
|
||||
|
||||
18
web/src/server/db/schema/report-schedules.ts
Normal file
18
web/src/server/db/schema/report-schedules.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { pgTable, text, timestamp, index, uuid, boolean } from "drizzle-orm/pg-core";
|
||||
import { users } from "./auth";
|
||||
import { reportType } from "./enums";
|
||||
|
||||
export const reportSchedules = pgTable("report_schedules", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
enabled: boolean("enabled").default(true).notNull(),
|
||||
frequency: text("frequency").notNull(),
|
||||
reportType: reportType("report_type").notNull(),
|
||||
lastGeneratedAt: timestamp("last_generated_at", { withTimezone: true, mode: "date" }),
|
||||
nextScheduledAt: timestamp("next_scheduled_at", { withTimezone: true, mode: "date" }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).defaultNow().notNull().$onUpdate(() => new Date()),
|
||||
}, (table) => ({
|
||||
userIdIdx: index("report_schedules_user_id_idx").on(table.userId),
|
||||
enabledIdx: index("report_schedules_enabled_idx").on(table.enabled),
|
||||
}));
|
||||
291
web/src/server/services/reports.service.test.ts
Normal file
291
web/src/server/services/reports.service.test.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
vi.mock("~/server/db", () => ({
|
||||
db: {
|
||||
query: {
|
||||
subscriptions: { findFirst: vi.fn() },
|
||||
},
|
||||
select: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("~/server/services/reports/generator", () => ({
|
||||
compileData: vi.fn(),
|
||||
renderHTML: vi.fn(),
|
||||
generatePDF: vi.fn(),
|
||||
uploadPDF: vi.fn(),
|
||||
}));
|
||||
|
||||
async function getDb() {
|
||||
return vi.mocked((await import("~/server/db")).db);
|
||||
}
|
||||
|
||||
function setupDefaults(db: Awaited<ReturnType<typeof getDb>>) {
|
||||
const limitFn = vi.fn().mockResolvedValue([]);
|
||||
const orderByFn = vi.fn().mockResolvedValue([]);
|
||||
db.select.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({ limit: limitFn, orderBy: orderByFn }),
|
||||
}),
|
||||
});
|
||||
db.insert.mockReturnValue({
|
||||
values: vi.fn().mockReturnValue({ returning: vi.fn() }),
|
||||
});
|
||||
db.update.mockReturnValue({
|
||||
set: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
});
|
||||
db.delete.mockReturnValue({
|
||||
where: vi.fn(),
|
||||
});
|
||||
}
|
||||
|
||||
const mockSub = {
|
||||
id: "sub-1",
|
||||
userId: "user-1",
|
||||
tier: "premium" as const,
|
||||
status: "active" as const,
|
||||
stripeId: null as string | null,
|
||||
familyGroupId: null as string | null,
|
||||
currentPeriodStart: new Date(),
|
||||
currentPeriodEnd: new Date(Date.now() + 86400000),
|
||||
cancelAtPeriodEnd: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
function setupSelect(db: Awaited<ReturnType<typeof getDb>>, overrides: {
|
||||
limit?: ReturnType<typeof vi.fn>;
|
||||
orderBy?: ReturnType<typeof vi.fn>;
|
||||
}) {
|
||||
db.select.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: overrides.limit ?? vi.fn().mockResolvedValue([]),
|
||||
orderBy: overrides.orderBy ?? vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetAllMocks();
|
||||
const db = await getDb();
|
||||
setupDefaults(db);
|
||||
});
|
||||
|
||||
describe("getReports", () => {
|
||||
it("returns paginated reports for user with active subscription", async () => {
|
||||
const db = await getDb();
|
||||
(db.query.subscriptions.findFirst as ReturnType<typeof vi.fn>).mockResolvedValue(mockSub);
|
||||
|
||||
const subLimitFn = vi.fn().mockResolvedValue([mockSub]);
|
||||
const offsetFn = vi.fn().mockResolvedValue([
|
||||
{ id: "r1", title: "Report 1" },
|
||||
{ id: "r2", title: "Report 2" },
|
||||
]);
|
||||
const dataWhere = {
|
||||
limit: vi.fn(),
|
||||
orderBy: vi.fn().mockReturnValue({ limit: vi.fn().mockReturnValue({ offset: offsetFn }) }),
|
||||
};
|
||||
|
||||
(db.select as ReturnType<typeof vi.fn>)
|
||||
.mockReturnValueOnce({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({ limit: subLimitFn, orderBy: vi.fn() }),
|
||||
}),
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue([{ count: 2 }]),
|
||||
}),
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue(dataWhere) }),
|
||||
});
|
||||
|
||||
const { getReports } = await import("./reports.service");
|
||||
const result = await getReports("user-1", { page: 1, limit: 20 });
|
||||
|
||||
expect(result.items).toHaveLength(2);
|
||||
expect(result.total).toBe(2);
|
||||
expect(result.page).toBe(1);
|
||||
});
|
||||
|
||||
it("throws not found if no active subscription", async () => {
|
||||
setupSelect(await getDb(), { limit: vi.fn().mockResolvedValue([]) });
|
||||
|
||||
const { getReports } = await import("./reports.service");
|
||||
await expect(getReports("user-1")).rejects.toThrow(TRPCError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getReport", () => {
|
||||
it("returns a single report by id", async () => {
|
||||
const db = await getDb();
|
||||
const limitFn = vi.fn()
|
||||
.mockResolvedValueOnce([mockSub])
|
||||
.mockResolvedValueOnce([{ id: "r1", title: "Test Report", status: "COMPLETED" }]);
|
||||
setupSelect(db, { limit: limitFn });
|
||||
|
||||
const { getReport } = await import("./reports.service");
|
||||
const result = await getReport("user-1", "r1");
|
||||
|
||||
expect(result.id).toBe("r1");
|
||||
expect(result.title).toBe("Test Report");
|
||||
});
|
||||
|
||||
it("throws not found if report does not exist", async () => {
|
||||
const db = await getDb();
|
||||
const limitFn = vi.fn()
|
||||
.mockResolvedValueOnce([mockSub])
|
||||
.mockResolvedValueOnce([]);
|
||||
setupSelect(db, { limit: limitFn });
|
||||
|
||||
const { getReport } = await import("./reports.service");
|
||||
await expect(getReport("user-1", "nonexistent")).rejects.toThrow(TRPCError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateReport", () => {
|
||||
it("creates a report record and returns reportId", async () => {
|
||||
const db = await getDb();
|
||||
setupSelect(db, { limit: vi.fn().mockResolvedValue([mockSub]) });
|
||||
db.insert.mockReturnValue({
|
||||
values: vi.fn().mockReturnValue({
|
||||
returning: vi.fn().mockResolvedValue([{ id: "r1", status: "PENDING" }]),
|
||||
}),
|
||||
});
|
||||
|
||||
const { generateReport } = await import("./reports.service");
|
||||
const result = await generateReport("user-1", "MONTHLY_PLUS");
|
||||
|
||||
expect(result.reportId).toBe("r1");
|
||||
});
|
||||
|
||||
it("throws forbidden if tier too low for premium report", async () => {
|
||||
const db = await getDb();
|
||||
setupSelect(db, { limit: vi.fn().mockResolvedValue([{ ...mockSub, tier: "basic" }]) });
|
||||
|
||||
const { generateReport } = await import("./reports.service");
|
||||
await expect(generateReport("user-1", "ANNUAL_PREMIUM")).rejects.toThrow(TRPCError);
|
||||
});
|
||||
|
||||
it("allows basic tier for weekly digest", async () => {
|
||||
const db = await getDb();
|
||||
setupSelect(db, { limit: vi.fn().mockResolvedValue([{ ...mockSub, tier: "basic" }]) });
|
||||
db.insert.mockReturnValue({
|
||||
values: vi.fn().mockReturnValue({
|
||||
returning: vi.fn().mockResolvedValue([{ id: "r1", status: "PENDING" }]),
|
||||
}),
|
||||
});
|
||||
|
||||
const { generateReport } = await import("./reports.service");
|
||||
const result = await generateReport("user-1", "WEEKLY_DIGEST");
|
||||
|
||||
expect(result.reportId).toBe("r1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteReport", () => {
|
||||
it("deletes a report that belongs to user", async () => {
|
||||
const db = await getDb();
|
||||
const limitFn = vi.fn()
|
||||
.mockResolvedValueOnce([mockSub])
|
||||
.mockResolvedValueOnce([{ id: "r1", title: "Test Report" }]);
|
||||
setupSelect(db, { limit: limitFn });
|
||||
db.delete.mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue([{ id: "r1" }]),
|
||||
});
|
||||
|
||||
const { deleteReport } = await import("./reports.service");
|
||||
const result = await deleteReport("user-1", "r1");
|
||||
|
||||
expect(result.deleted).toBe(true);
|
||||
});
|
||||
|
||||
it("throws not found if report does not belong to user", async () => {
|
||||
const db = await getDb();
|
||||
const limitFn = vi.fn()
|
||||
.mockResolvedValueOnce([mockSub])
|
||||
.mockResolvedValueOnce([]);
|
||||
setupSelect(db, { limit: limitFn });
|
||||
|
||||
const { deleteReport } = await import("./reports.service");
|
||||
await expect(deleteReport("user-1", "nonexistent")).rejects.toThrow(TRPCError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getScheduledReports", () => {
|
||||
it("returns schedules for user", async () => {
|
||||
const db = await getDb();
|
||||
const orderByFn = vi.fn().mockResolvedValue([
|
||||
{ id: "s1", enabled: true, frequency: "weekly", reportType: "WEEKLY_DIGEST" },
|
||||
]);
|
||||
setupSelect(db, { orderBy: orderByFn });
|
||||
|
||||
const { getScheduledReports } = await import("./reports.service");
|
||||
const result = await getScheduledReports("user-1");
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].frequency).toBe("weekly");
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateSchedule", () => {
|
||||
it("creates a new schedule when none exists", async () => {
|
||||
const db = await getDb();
|
||||
setupSelect(db, { limit: vi.fn().mockResolvedValue([]) });
|
||||
db.insert.mockReturnValue({
|
||||
values: vi.fn().mockReturnValue({
|
||||
returning: vi.fn().mockResolvedValue([{
|
||||
id: "s1", userId: "user-1", enabled: true, frequency: "monthly", reportType: "MONTHLY_PLUS",
|
||||
}]),
|
||||
}),
|
||||
});
|
||||
|
||||
const { updateSchedule } = await import("./reports.service");
|
||||
const result = await updateSchedule("user-1", {
|
||||
enabled: true,
|
||||
frequency: "monthly",
|
||||
reportType: "MONTHLY_PLUS",
|
||||
});
|
||||
|
||||
expect(result.id).toBe("s1");
|
||||
expect(result.frequency).toBe("monthly");
|
||||
});
|
||||
|
||||
it("updates existing schedule", async () => {
|
||||
const db = await getDb();
|
||||
setupSelect(db, {
|
||||
limit: vi.fn().mockResolvedValue([
|
||||
{ id: "s1", enabled: true, frequency: "weekly", reportType: "WEEKLY_DIGEST" },
|
||||
]),
|
||||
});
|
||||
db.update.mockReturnValue({
|
||||
set: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
returning: vi.fn().mockResolvedValue([{
|
||||
id: "s1", enabled: false, frequency: "monthly", reportType: "WEEKLY_DIGEST",
|
||||
}]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const { updateSchedule } = await import("./reports.service");
|
||||
const result = await updateSchedule("user-1", {
|
||||
enabled: false,
|
||||
frequency: "monthly",
|
||||
reportType: "WEEKLY_DIGEST",
|
||||
});
|
||||
|
||||
expect(result.id).toBe("s1");
|
||||
expect(result.enabled).toBe(false);
|
||||
expect(result.frequency).toBe("monthly");
|
||||
});
|
||||
});
|
||||
221
web/src/server/services/reports.service.ts
Normal file
221
web/src/server/services/reports.service.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq, and, desc, count } from "drizzle-orm";
|
||||
import { db } from "~/server/db";
|
||||
import { subscriptions, securityReports, reportSchedules } from "~/server/db/schema";
|
||||
import { compileData, renderHTML, generatePDF, uploadPDF } from "./reports/generator";
|
||||
|
||||
async function getSubscription(userId: string) {
|
||||
const [sub] = await db
|
||||
.select()
|
||||
.from(subscriptions)
|
||||
.where(and(eq(subscriptions.userId, userId), eq(subscriptions.status, "active")))
|
||||
.limit(1);
|
||||
if (!sub) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "No active subscription found" });
|
||||
}
|
||||
return sub;
|
||||
}
|
||||
|
||||
function getReportTypeLabel(reportType: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
MONTHLY_PLUS: "Monthly",
|
||||
ANNUAL_PREMIUM: "Annual",
|
||||
WEEKLY_DIGEST: "Weekly",
|
||||
};
|
||||
return labels[reportType] ?? reportType;
|
||||
}
|
||||
|
||||
export async function getReports(
|
||||
userId: string,
|
||||
filters?: { page?: number; limit?: number },
|
||||
) {
|
||||
const sub = await getSubscription(userId);
|
||||
const page = filters?.page ?? 1;
|
||||
const limit = filters?.limit ?? 20;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const [totalResult] = await db
|
||||
.select({ count: count() })
|
||||
.from(securityReports)
|
||||
.where(eq(securityReports.subscriptionId, sub.id));
|
||||
|
||||
const items = await db
|
||||
.select()
|
||||
.from(securityReports)
|
||||
.where(eq(securityReports.subscriptionId, sub.id))
|
||||
.orderBy(desc(securityReports.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return {
|
||||
items,
|
||||
total: totalResult.count,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(totalResult.count / limit),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getReport(userId: string, reportId: string) {
|
||||
const sub = await getSubscription(userId);
|
||||
|
||||
const [report] = await db
|
||||
.select()
|
||||
.from(securityReports)
|
||||
.where(and(eq(securityReports.id, reportId), eq(securityReports.subscriptionId, sub.id)))
|
||||
.limit(1);
|
||||
|
||||
if (!report) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Report not found" });
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
export async function generateReport(
|
||||
userId: string,
|
||||
reportType: "MONTHLY_PLUS" | "ANNUAL_PREMIUM" | "WEEKLY_DIGEST",
|
||||
periodStartStr?: string,
|
||||
periodEndStr?: string,
|
||||
) {
|
||||
const sub = await getSubscription(userId);
|
||||
|
||||
const requiredTier = reportType === "ANNUAL_PREMIUM" ? "premium" : reportType === "MONTHLY_PLUS" ? "plus" : "basic";
|
||||
const tiers: Record<string, number> = { basic: 0, plus: 1, premium: 2 };
|
||||
if ((tiers[sub.tier] ?? 0) < tiers[requiredTier]) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: `${getReportTypeLabel(reportType)} reports require ${requiredTier} tier subscription`,
|
||||
});
|
||||
}
|
||||
|
||||
const periodStart = periodStartStr ? new Date(periodStartStr) : undefined;
|
||||
const periodEnd = periodEndStr ? new Date(periodEndStr) : undefined;
|
||||
|
||||
const reportLabel = getReportTypeLabel(reportType);
|
||||
const title = `ShieldAI ${reportLabel} Security Report`;
|
||||
|
||||
const [report] = await db
|
||||
.insert(securityReports)
|
||||
.values({
|
||||
userId,
|
||||
subscriptionId: sub.id,
|
||||
reportType,
|
||||
status: "PENDING",
|
||||
periodStart: periodStart ?? new Date(),
|
||||
periodEnd: periodEnd ?? new Date(),
|
||||
title,
|
||||
})
|
||||
.returning();
|
||||
|
||||
generateReportAsync(report.id, userId, reportType, periodStart, periodEnd).catch((err) => {
|
||||
console.error("[reports] Generation failed:", err);
|
||||
db.update(securityReports)
|
||||
.set({ status: "FAILED", error: err instanceof Error ? err.message : "Unknown error" })
|
||||
.where(eq(securityReports.id, report.id))
|
||||
.then(() => undefined);
|
||||
});
|
||||
|
||||
return { reportId: report.id };
|
||||
}
|
||||
|
||||
async function generateReportAsync(
|
||||
reportId: string,
|
||||
userId: string,
|
||||
reportType: "MONTHLY_PLUS" | "ANNUAL_PREMIUM" | "WEEKLY_DIGEST",
|
||||
periodStart?: Date,
|
||||
periodEnd?: Date,
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(securityReports)
|
||||
.set({ status: "GENERATING" })
|
||||
.where(eq(securityReports.id, reportId));
|
||||
|
||||
const data = await compileData(userId, reportType, periodStart, periodEnd);
|
||||
|
||||
const html = renderHTML(data, reportType);
|
||||
|
||||
const pdfBuffer = await generatePDF(html);
|
||||
|
||||
const filename = `${reportType.toLowerCase()}-${reportId}.pdf`;
|
||||
const pdfUrl = await uploadPDF(userId, pdfBuffer, filename);
|
||||
|
||||
await db
|
||||
.update(securityReports)
|
||||
.set({
|
||||
status: "COMPLETED",
|
||||
htmlContent: html,
|
||||
pdfUrl,
|
||||
dataPayload: data as never,
|
||||
summary: data.summary,
|
||||
periodStart: periodStart ?? new Date(data.periodStart),
|
||||
periodEnd: periodEnd ?? new Date(data.periodEnd),
|
||||
})
|
||||
.where(eq(securityReports.id, reportId));
|
||||
}
|
||||
|
||||
export async function deleteReport(userId: string, reportId: string) {
|
||||
const sub = await getSubscription(userId);
|
||||
|
||||
const [report] = await db
|
||||
.select()
|
||||
.from(securityReports)
|
||||
.where(and(eq(securityReports.id, reportId), eq(securityReports.subscriptionId, sub.id)))
|
||||
.limit(1);
|
||||
|
||||
if (!report) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Report not found" });
|
||||
}
|
||||
|
||||
await db.delete(securityReports).where(eq(securityReports.id, reportId));
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
export async function getScheduledReports(userId: string) {
|
||||
const schedules = await db
|
||||
.select()
|
||||
.from(reportSchedules)
|
||||
.where(eq(reportSchedules.userId, userId))
|
||||
.orderBy(desc(reportSchedules.createdAt));
|
||||
|
||||
return schedules;
|
||||
}
|
||||
|
||||
export async function updateSchedule(
|
||||
userId: string,
|
||||
schedule: {
|
||||
enabled: boolean;
|
||||
frequency: "weekly" | "monthly" | "quarterly";
|
||||
reportType: "MONTHLY_PLUS" | "ANNUAL_PREMIUM" | "WEEKLY_DIGEST";
|
||||
},
|
||||
) {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(reportSchedules)
|
||||
.where(and(eq(reportSchedules.userId, userId), eq(reportSchedules.reportType, schedule.reportType)))
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
const [updated] = await db
|
||||
.update(reportSchedules)
|
||||
.set({
|
||||
enabled: schedule.enabled,
|
||||
frequency: schedule.frequency,
|
||||
})
|
||||
.where(eq(reportSchedules.id, existing.id))
|
||||
.returning();
|
||||
return updated;
|
||||
}
|
||||
|
||||
const [created] = await db
|
||||
.insert(reportSchedules)
|
||||
.values({
|
||||
userId,
|
||||
enabled: schedule.enabled,
|
||||
frequency: schedule.frequency,
|
||||
reportType: schedule.reportType,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return created;
|
||||
}
|
||||
275
web/src/server/services/reports/generator.ts
Normal file
275
web/src/server/services/reports/generator.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { eq, and, gte, lte, count } from "drizzle-orm";
|
||||
import { db } from "~/server/db";
|
||||
import { subscriptions, normalizedAlerts, exposures, voiceAnalyses, spamFeedback, propertyChanges, securityReports } from "~/server/db/schema";
|
||||
|
||||
const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
||||
const TEMPLATES_DIR = join(__dirname, "templates");
|
||||
const REPORTS_DIR = join(process.cwd(), "reports");
|
||||
|
||||
export interface ReportData {
|
||||
title: string;
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
summary: string;
|
||||
threatScore: number;
|
||||
threatLevel: "low" | "medium" | "high";
|
||||
threatTrend: string;
|
||||
alertCount: number;
|
||||
exposureCount: number;
|
||||
voiceAnalysisCount: number;
|
||||
spamDetectionCount: number;
|
||||
propertyChangeCount: number;
|
||||
alertBreakdownRows: string;
|
||||
recommendations: string;
|
||||
generatedAt: string;
|
||||
breakdownRows?: string;
|
||||
recentAlerts?: string;
|
||||
}
|
||||
|
||||
function getTier(reportType: string): string {
|
||||
if (reportType === "MONTHLY_PLUS") return "plus";
|
||||
if (reportType === "ANNUAL_PREMIUM") return "premium";
|
||||
return "basic";
|
||||
}
|
||||
|
||||
function getDefaultPeriod(reportType: string): { periodStart: Date; periodEnd: Date } {
|
||||
const now = new Date();
|
||||
const periodEnd = now;
|
||||
let periodStart: Date;
|
||||
if (reportType === "WEEKLY_DIGEST") {
|
||||
periodStart = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
} else if (reportType === "MONTHLY_PLUS") {
|
||||
periodStart = new Date(now.getFullYear(), now.getMonth() - 1, now.getDate());
|
||||
} else {
|
||||
periodStart = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
|
||||
}
|
||||
return { periodStart, periodEnd };
|
||||
}
|
||||
|
||||
export async function compileData(
|
||||
userId: string,
|
||||
reportType: string,
|
||||
periodStart?: Date,
|
||||
periodEnd?: Date,
|
||||
): Promise<ReportData> {
|
||||
const { periodStart: ps, periodEnd: pe } =
|
||||
periodStart && periodEnd ? { periodStart, periodEnd } : getDefaultPeriod(reportType);
|
||||
|
||||
const [sub] = await db
|
||||
.select()
|
||||
.from(subscriptions)
|
||||
.where(and(eq(subscriptions.userId, userId), eq(subscriptions.status, "active")))
|
||||
.limit(1);
|
||||
|
||||
const subId = sub?.id;
|
||||
|
||||
const conditions = subId ? [eq(normalizedAlerts.userId, userId)] : [eq(normalizedAlerts.userId, userId)];
|
||||
const alertConditions = [...conditions, gte(normalizedAlerts.createdAt, ps), lte(normalizedAlerts.createdAt, pe)];
|
||||
const prevAlertConditions = [
|
||||
...conditions,
|
||||
gte(normalizedAlerts.createdAt, new Date(ps.getTime() - (pe.getTime() - ps.getTime()))),
|
||||
lte(normalizedAlerts.createdAt, ps),
|
||||
];
|
||||
|
||||
const [alertTotal] = await db
|
||||
.select({ count: count() })
|
||||
.from(normalizedAlerts)
|
||||
.where(and(...alertConditions));
|
||||
|
||||
const [prevAlertTotal] = await db
|
||||
.select({ count: count() })
|
||||
.from(normalizedAlerts)
|
||||
.where(and(...prevAlertConditions));
|
||||
|
||||
const expConditions = subId
|
||||
? [eq(exposures.subscriptionId, subId), gte(exposures.detectedAt, ps), lte(exposures.detectedAt, pe)]
|
||||
: [gte(exposures.detectedAt, ps as Date), lte(exposures.detectedAt, pe as Date)];
|
||||
|
||||
const [exposureTotal] = await db
|
||||
.select({ count: count() })
|
||||
.from(exposures)
|
||||
.where(and(...expConditions));
|
||||
|
||||
const voiceConditions = [eq(voiceAnalyses.userId, userId), gte(voiceAnalyses.createdAt, ps), lte(voiceAnalyses.createdAt, pe)];
|
||||
const [voiceTotal] = await db
|
||||
.select({ count: count() })
|
||||
.from(voiceAnalyses)
|
||||
.where(and(...voiceConditions));
|
||||
|
||||
const spamConditions = [eq(spamFeedback.userId, userId), gte(spamFeedback.createdAt, ps), lte(spamFeedback.createdAt, pe)];
|
||||
const [spamTotal] = await db
|
||||
.select({ count: count() })
|
||||
.from(spamFeedback)
|
||||
.where(and(...spamConditions));
|
||||
|
||||
const propConditions = subId
|
||||
? [eq(propertyChanges.propertyWatchlistItemId, subId), gte(propertyChanges.detectedAt, ps), lte(propertyChanges.detectedAt, pe)]
|
||||
: [];
|
||||
let propTotal = { count: 0 };
|
||||
if (propConditions.length >= 3) {
|
||||
[propTotal] = await db
|
||||
.select({ count: count() })
|
||||
.from(propertyChanges)
|
||||
.where(and(...propConditions));
|
||||
}
|
||||
|
||||
const alertCount = alertTotal.count;
|
||||
const prevAlertCount = prevAlertTotal.count;
|
||||
const exposureCount = exposureTotal.count;
|
||||
const voiceAnalysisCount = voiceTotal.count;
|
||||
const spamDetectionCount = spamTotal.count;
|
||||
|
||||
const totalScore = alertCount + exposureCount * 2 + voiceAnalysisCount + spamDetectionCount * 0.5;
|
||||
const prevTotalScore = prevAlertCount;
|
||||
const threatScore = Math.min(100, Math.round((totalScore / Math.max(1, prevTotalScore || 1)) * 50));
|
||||
|
||||
const threatLevel = threatScore < 33 ? "low" : threatScore < 66 ? "medium" : "high";
|
||||
|
||||
let threatTrend: string;
|
||||
const diff = threatScore - 50;
|
||||
if (Math.abs(diff) < 5) {
|
||||
threatTrend = "Stable compared to previous period";
|
||||
} else if (diff > 0) {
|
||||
threatTrend = `Increased by ${diff}% compared to previous period`;
|
||||
} else {
|
||||
threatTrend = `Decreased by ${Math.abs(diff)}% compared to previous period`;
|
||||
}
|
||||
|
||||
const alertSources = await db
|
||||
.select({ source: normalizedAlerts.source })
|
||||
.from(normalizedAlerts)
|
||||
.where(and(...alertConditions));
|
||||
|
||||
const sourceCounts: Record<string, number> = {};
|
||||
for (const a of alertSources) {
|
||||
sourceCounts[a.source] = (sourceCounts[a.source] || 0) + 1;
|
||||
}
|
||||
|
||||
const alertBreakdownRows = Object.entries(sourceCounts)
|
||||
.map(([source, count]) => {
|
||||
const critical = Math.round(count * 0.2);
|
||||
const warning = Math.round(count * 0.3);
|
||||
const info = count - critical - warning;
|
||||
return `<tr><td>${source}</td><td><span class="severity-badge critical">${critical}</span></td><td><span class="severity-badge warning">${warning}</span></td><td><span class="severity-badge info">${info}</span></td><td>${count}</td></tr>`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
const recommendations = compileRecommendations(alertCount, exposureCount, voiceAnalysisCount, spamDetectionCount);
|
||||
|
||||
const generatedAt = new Date().toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
const title = `ShieldAI ${reportType === "WEEKLY_DIGEST" ? "Weekly" : reportType === "MONTHLY_PLUS" ? "Monthly" : "Annual"} Security Report`;
|
||||
|
||||
return {
|
||||
title,
|
||||
periodStart: ps.toLocaleDateString(),
|
||||
periodEnd: pe.toLocaleDateString(),
|
||||
summary: `During this period, ShieldAI detected ${alertCount} security alerts, ${exposureCount} data exposures, ${voiceAnalysisCount} voice analysis events, ${spamDetectionCount} spam detections, and ${propTotal.count} property changes.`,
|
||||
threatScore,
|
||||
threatLevel,
|
||||
threatTrend,
|
||||
alertCount,
|
||||
exposureCount,
|
||||
voiceAnalysisCount,
|
||||
spamDetectionCount,
|
||||
propertyChangeCount: propTotal.count,
|
||||
alertBreakdownRows: alertBreakdownRows || "<tr><td colspan='5' style='text-align:center;color:var(--muted)'>No alerts in this period</td></tr>",
|
||||
recommendations,
|
||||
generatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function compileRecommendations(
|
||||
alertCount: number,
|
||||
exposureCount: number,
|
||||
voiceAnalysisCount: number,
|
||||
spamDetectionCount: number,
|
||||
): string {
|
||||
const items: string[] = [];
|
||||
|
||||
if (exposureCount > 0) {
|
||||
items.push(
|
||||
`<div class="recommendation urgent">🔴 <strong>Immediate Action Required:</strong> ${exposureCount} data exposure(s) detected. Review exposed credentials and enable two-factor authentication.</div>`,
|
||||
);
|
||||
}
|
||||
if (alertCount > 5) {
|
||||
items.push(
|
||||
`<div class="recommendation">🟡 <strong>Review Alert Settings:</strong> You received ${alertCount} alerts this period. Consider adjusting notification preferences to reduce noise.</div>`,
|
||||
);
|
||||
}
|
||||
if (voiceAnalysisCount > 0) {
|
||||
items.push(
|
||||
`<div class="recommendation">🟢 <strong>Voice Security:</strong> Monitor voice call activity regularly. ShieldAI flagged ${voiceAnalysisCount} analysis event(s) this period.</div>`,
|
||||
);
|
||||
}
|
||||
if (spamDetectionCount > 5) {
|
||||
items.push(
|
||||
`<div class="recommendation">🟡 <strong>Spam Activity Elevated:</strong> ${spamDetectionCount} spam detection(s). Review rules and block unknown callers.</div>`,
|
||||
);
|
||||
}
|
||||
items.push(
|
||||
`<div class="recommendation">ℹ️ <strong>Stay Proactive:</strong> Regularly review your ShieldAI dashboard for real-time security updates and run DarkWatch scans weekly.</div>`,
|
||||
);
|
||||
|
||||
return items.join("\n");
|
||||
}
|
||||
|
||||
function loadTemplate(reportType: string): string {
|
||||
const templateMap: Record<string, string> = {
|
||||
MONTHLY_PLUS: "monthly-plus.html",
|
||||
ANNUAL_PREMIUM: "annual-premium.html",
|
||||
WEEKLY_DIGEST: "weekly-digest.html",
|
||||
};
|
||||
const filename = templateMap[reportType] || "monthly-plus.html";
|
||||
return readFileSync(join(TEMPLATES_DIR, filename), "utf-8");
|
||||
}
|
||||
|
||||
function renderTemplate(template: string, data: Record<string, string>): string {
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (_: string, key: string) => {
|
||||
return data[key] !== undefined ? data[key] : `{{${key}}}`;
|
||||
});
|
||||
}
|
||||
|
||||
export function renderHTML(data: ReportData, reportType: string): string {
|
||||
const template = loadTemplate(reportType);
|
||||
const flatData: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
flatData[key] = String(value);
|
||||
}
|
||||
return renderTemplate(template, flatData);
|
||||
}
|
||||
|
||||
export async function generatePDF(html: string): Promise<Buffer> {
|
||||
try {
|
||||
const puppeteer = await import("puppeteer");
|
||||
const browser = await puppeteer.launch({ headless: true, args: ["--no-sandbox"] });
|
||||
const page = await browser.newPage();
|
||||
await page.setContent(html, { waitUntil: "load" });
|
||||
const pdfBuffer = await page.pdf({ format: "A4", printBackground: true, margin: { top: "20mm", bottom: "20mm", left: "15mm", right: "15mm" } });
|
||||
await browser.close();
|
||||
return Buffer.from(pdfBuffer);
|
||||
} catch {
|
||||
return Buffer.from(html);
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadPDF(
|
||||
userId: string,
|
||||
pdfBuffer: Buffer,
|
||||
filename: string,
|
||||
): Promise<string> {
|
||||
const userDir = join(REPORTS_DIR, userId);
|
||||
if (!existsSync(userDir)) {
|
||||
mkdirSync(userDir, { recursive: true });
|
||||
}
|
||||
const filePath = join(userDir, filename);
|
||||
writeFileSync(filePath, pdfBuffer);
|
||||
return filePath;
|
||||
}
|
||||
115
web/src/server/services/reports/templates/annual-premium.html
Normal file
115
web/src/server/services/reports/templates/annual-premium.html
Normal file
@@ -0,0 +1,115 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{title}} — ShieldAI Annual Report</title>
|
||||
<style>
|
||||
:root {
|
||||
--primary: #1a73e8;
|
||||
--bg: #f8fafc;
|
||||
--card: #ffffff;
|
||||
--text: #1e293b;
|
||||
--muted: #64748b;
|
||||
--critical: #dc2626;
|
||||
--warning: #f59e0b;
|
||||
--info: #3b82f6;
|
||||
--success: #16a34a;
|
||||
--border: #e2e8f0;
|
||||
}
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg); color: var(--text); margin: 0; padding: 0; line-height: 1.6; }
|
||||
.container { max-width: 800px; margin: 0 auto; padding: 32px 24px; }
|
||||
.header { text-align: center; padding: 48px 0; border-bottom: 3px solid var(--primary); margin-bottom: 32px; }
|
||||
.header .logo { font-size: 42px; font-weight: 800; letter-spacing: -1px; color: var(--primary); }
|
||||
.header .logo span { color: #0f172a; }
|
||||
.header h1 { color: var(--primary); margin: 16px 0 8px; font-size: 32px; }
|
||||
.header .subtitle { color: var(--muted); font-size: 15px; }
|
||||
.section { background: var(--card); border-radius: 12px; padding: 28px; margin-bottom: 24px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
|
||||
.section h2 { font-size: 20px; margin: 0 0 20px; color: var(--primary); border-bottom: 1px solid var(--border); padding-bottom: 12px; }
|
||||
.section h3 { font-size: 16px; margin: 16px 0 8px; color: var(--text); }
|
||||
.summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 16px; }
|
||||
.summary-card { text-align: center; padding: 20px 12px; background: var(--bg); border-radius: 8px; }
|
||||
.summary-card .value { font-size: 32px; font-weight: 700; color: var(--primary); }
|
||||
.summary-card .label { font-size: 12px; color: var(--muted); margin-top: 6px; }
|
||||
.summary-card .trend { font-size: 13px; font-weight: 600; margin-top: 4px; }
|
||||
.threat-score { font-size: 56px; font-weight: 800; text-align: center; padding: 24px; }
|
||||
.threat-score.low { color: var(--success); }
|
||||
.threat-score.medium { color: var(--warning); }
|
||||
.threat-score.high { color: var(--critical); }
|
||||
.trend-up { color: var(--critical); }
|
||||
.trend-down { color: var(--success); }
|
||||
.trend-neutral { color: var(--muted); }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--border); font-size: 14px; }
|
||||
th { color: var(--muted); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.recommendation { padding: 14px 18px; background: #f0fdf4; border-left: 4px solid var(--success); border-radius: 6px; margin-bottom: 10px; font-size: 14px; }
|
||||
.recommendation.urgent { background: #fef2f2; border-left-color: var(--critical); }
|
||||
.year-summary { display: flex; justify-content: space-around; padding: 16px 0; }
|
||||
.year-stat { text-align: center; }
|
||||
.year-stat .num { font-size: 24px; font-weight: 700; color: var(--primary); }
|
||||
.year-stat .desc { font-size: 12px; color: var(--muted); }
|
||||
.footer { text-align: center; padding: 24px; color: var(--muted); font-size: 12px; border-top: 1px solid var(--border); margin-top: 32px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="logo">Shield<span>AI</span></div>
|
||||
<h1>{{title}}</h1>
|
||||
<div class="subtitle">{{periodStart}} — {{periodEnd}} | Annual Comprehensive Security Report</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Executive Summary</h2>
|
||||
<p>{{summary}}</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Annual Threat Score</h2>
|
||||
<div class="threat-score {{threatLevel}}">{{threatScore}}</div>
|
||||
<div style="text-align:center;color:var(--muted);font-size:14px;">{{threatTrend}}</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Year at a Glance</h2>
|
||||
<div class="year-summary">
|
||||
<div class="year-stat"><div class="num">{{alertCount}}</div><div class="desc">Total Alerts</div></div>
|
||||
<div class="year-stat"><div class="num">{{exposureCount}}</div><div class="desc">Exposures Found</div></div>
|
||||
<div class="year-stat"><div class="num">{{voiceAnalysisCount}}</div><div class="desc">Voice Analyses</div></div>
|
||||
<div class="year-stat"><div class="num">{{spamDetectionCount}}</div><div class="desc">Spam Detections</div></div>
|
||||
<div class="year-stat"><div class="num">{{propertyChangeCount}}</div><div class="desc">Property Changes</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Monthly Breakdown</h2>
|
||||
<table>
|
||||
<thead><tr><th>Category</th><th>Count</th><th>vs Previous Year</th></tr></thead>
|
||||
<tbody>
|
||||
{{breakdownRows}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Alert Breakdown by Service</h2>
|
||||
<table>
|
||||
<thead><tr><th>Service</th><th>Critical</th><th>Warning</th><th>Info</th><th>Total</th></tr></thead>
|
||||
<tbody>
|
||||
{{alertBreakdownRows}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Recommendations</h2>
|
||||
{{recommendations}}
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>Generated by ShieldAI on {{generatedAt}}</p>
|
||||
<p>This report contains sensitive security information. Please keep it confidential.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
102
web/src/server/services/reports/templates/monthly-plus.html
Normal file
102
web/src/server/services/reports/templates/monthly-plus.html
Normal file
@@ -0,0 +1,102 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{title}} — ShieldAI Monthly Report</title>
|
||||
<style>
|
||||
:root {
|
||||
--primary: #1a73e8;
|
||||
--bg: #f8fafc;
|
||||
--card: #ffffff;
|
||||
--text: #1e293b;
|
||||
--muted: #64748b;
|
||||
--critical: #dc2626;
|
||||
--warning: #f59e0b;
|
||||
--info: #3b82f6;
|
||||
--border: #e2e8f0;
|
||||
}
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg); color: var(--text); margin: 0; padding: 0; line-height: 1.6; }
|
||||
.container { max-width: 800px; margin: 0 auto; padding: 32px 24px; }
|
||||
.header { text-align: center; padding: 40px 0; border-bottom: 2px solid var(--primary); margin-bottom: 32px; }
|
||||
.header h1 { color: var(--primary); margin: 0 0 8px; font-size: 28px; }
|
||||
.header .subtitle { color: var(--muted); font-size: 14px; }
|
||||
.header .logo { font-size: 36px; font-weight: 800; letter-spacing: -1px; color: var(--primary); }
|
||||
.header .logo span { color: #0f172a; }
|
||||
.section { background: var(--card); border-radius: 12px; padding: 24px; margin-bottom: 24px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
|
||||
.section h2 { font-size: 18px; margin: 0 0 16px; color: var(--primary); border-bottom: 1px solid var(--border); padding-bottom: 12px; }
|
||||
.summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 16px; }
|
||||
.summary-card { text-align: center; padding: 16px; background: var(--bg); border-radius: 8px; }
|
||||
.summary-card .value { font-size: 28px; font-weight: 700; color: var(--primary); }
|
||||
.summary-card .label { font-size: 12px; color: var(--muted); margin-top: 4px; }
|
||||
.threat-score { font-size: 48px; font-weight: 800; text-align: center; padding: 24px; }
|
||||
.threat-score.low { color: #16a34a; }
|
||||
.threat-score.medium { color: #f59e0b; }
|
||||
.threat-score.high { color: #dc2626; }
|
||||
.severity-badge { display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; }
|
||||
.severity-badge.critical { background: #fef2f2; color: var(--critical); }
|
||||
.severity-badge.warning { background: #fefce8; color: var(--warning); }
|
||||
.severity-badge.info { background: #eff6ff; color: var(--info); }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--border); font-size: 14px; }
|
||||
th { color: var(--muted); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.trend-up { color: var(--critical); }
|
||||
.trend-down { color: #16a34a; }
|
||||
.trend-neutral { color: var(--muted); }
|
||||
.recommendation { padding: 12px 16px; background: #f0fdf4; border-left: 4px solid #16a34a; border-radius: 4px; margin-bottom: 8px; font-size: 14px; }
|
||||
.recommendation.urgent { background: #fef2f2; border-left-color: var(--critical); }
|
||||
.footer { text-align: center; padding: 24px; color: var(--muted); font-size: 12px; border-top: 1px solid var(--border); margin-top: 32px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="logo">Shield<span>AI</span></div>
|
||||
<h1>{{title}}</h1>
|
||||
<div class="subtitle">{{periodStart}} — {{periodEnd}} | Monthly Security Summary</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Executive Summary</h2>
|
||||
<p>{{summary}}</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Threat Score Trend</h2>
|
||||
<div class="threat-score {{threatLevel}}">{{threatScore}}</div>
|
||||
<div style="text-align:center;color:var(--muted);font-size:14px;">{{threatTrend}}</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Activity Summary</h2>
|
||||
<div class="summary-grid">
|
||||
<div class="summary-card"><div class="value">{{alertCount}}</div><div class="label">Alerts</div></div>
|
||||
<div class="summary-card"><div class="value">{{exposureCount}}</div><div class="label">Exposures</div></div>
|
||||
<div class="summary-card"><div class="value">{{voiceAnalysisCount}}</div><div class="label">Voice Analyses</div></div>
|
||||
<div class="summary-card"><div class="value">{{spamDetectionCount}}</div><div class="label">Spam Detections</div></div>
|
||||
<div class="summary-card"><div class="value">{{propertyChangeCount}}</div><div class="label">Property Changes</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Alert Breakdown by Service</h2>
|
||||
<table>
|
||||
<thead><tr><th>Service</th><th>Critical</th><th>Warning</th><th>Info</th><th>Total</th></tr></thead>
|
||||
<tbody>
|
||||
{{alertBreakdownRows}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Recommendations</h2>
|
||||
{{recommendations}}
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>Generated by ShieldAI on {{generatedAt}}</p>
|
||||
<p>This report contains sensitive security information. Please keep it confidential.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
64
web/src/server/services/reports/templates/weekly-digest.html
Normal file
64
web/src/server/services/reports/templates/weekly-digest.html
Normal file
@@ -0,0 +1,64 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{title}} — ShieldAI Weekly Digest</title>
|
||||
<style>
|
||||
:root {
|
||||
--primary: #1a73e8;
|
||||
--bg: #f8fafc;
|
||||
--card: #ffffff;
|
||||
--text: #1e293b;
|
||||
--muted: #64748b;
|
||||
--critical: #dc2626;
|
||||
--warning: #f59e0b;
|
||||
--info: #3b82f6;
|
||||
--border: #e2e8f0;
|
||||
}
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg); color: var(--text); margin: 0; padding: 0; line-height: 1.5; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 24px 16px; }
|
||||
.header { text-align: center; padding: 24px 0; border-bottom: 2px solid var(--primary); margin-bottom: 24px; }
|
||||
.header .logo { font-size: 28px; font-weight: 800; letter-spacing: -1px; color: var(--primary); }
|
||||
.header .logo span { color: #0f172a; }
|
||||
.header h1 { font-size: 22px; margin: 8px 0 4px; color: var(--text); }
|
||||
.header .subtitle { color: var(--muted); font-size: 13px; }
|
||||
.section { background: var(--card); border-radius: 8px; padding: 20px; margin-bottom: 16px; box-shadow: 0 1px 2px rgba(0,0,0,0.06); }
|
||||
.section h2 { font-size: 16px; margin: 0 0 12px; color: var(--primary); }
|
||||
.summary-row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 14px; }
|
||||
.summary-row:last-child { border-bottom: none; }
|
||||
.summary-row .value { font-weight: 600; }
|
||||
.recommendation { padding: 10px 14px; background: #f0fdf4; border-left: 3px solid #16a34a; border-radius: 4px; margin-bottom: 6px; font-size: 13px; }
|
||||
.recommendation.urgent { background: #fef2f2; border-left-color: var(--critical); }
|
||||
.footer { text-align: center; padding: 16px; color: var(--muted); font-size: 11px; border-top: 1px solid var(--border); margin-top: 24px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="logo">Shield<span>AI</span></div>
|
||||
<h1>Weekly Security Digest</h1>
|
||||
<div class="subtitle">{{periodStart}} — {{periodEnd}}</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>This Week at a Glance</h2>
|
||||
<div class="summary-row"><span>Alerts</span><span class="value">{{alertCount}}</span></div>
|
||||
<div class="summary-row"><span>Exposures Detected</span><span class="value">{{exposureCount}}</span></div>
|
||||
<div class="summary-row"><span>Voice Analysis Events</span><span class="value">{{voiceAnalysisCount}}</span></div>
|
||||
<div class="summary-row"><span>Spam Detections</span><span class="value">{{spamDetectionCount}}</span></div>
|
||||
<div class="summary-row"><span>Property Changes</span><span class="value">{{propertyChangeCount}}</span></div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Recommendations</h2>
|
||||
{{recommendations}}
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>Generated by ShieldAI on {{generatedAt}}</p>
|
||||
<p>This digest contains sensitive security information. Please keep it confidential.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user