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"]),
|
||||
});
|
||||
Reference in New Issue
Block a user