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:
2026-05-25 17:08:43 -04:00
parent 4f7882a10d
commit 659ab9b71a
16 changed files with 3102 additions and 10 deletions

View File

@@ -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;

View 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();
});
});

View 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);
}),
});

View 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"]),
});

View File

@@ -13,4 +13,5 @@ export * from "./hometitle";
export * from "./removebrokers";
export * from "./invitation";
export * from "./notifications";
export * from "./report-schedules";
export * from "./relations";

View File

@@ -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),

View 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),
}));

View 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");
});
});

View 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;
}

View 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;
}

View 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>

View 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>

View 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>