assets, move memories to proper location
This commit is contained in:
@@ -66,18 +66,24 @@ async function processReportGeneration(
|
||||
const { EmailService } = await import('@shieldai/shared-notifications');
|
||||
const emailService = EmailService.getInstance();
|
||||
|
||||
await emailService.send({
|
||||
channel: 'email',
|
||||
to: notifyEmail,
|
||||
subject: `ShieldAI: ${report.title} Ready`,
|
||||
htmlBody: `
|
||||
<h2>Your ShieldAI Protection Report is Ready</h2>
|
||||
<p><strong>${report.title}</strong></p>
|
||||
<p>${report.summary || 'View your report to see detailed protection statistics.'}</p>
|
||||
<p><a href="${process.env.DASHBOARD_URL || 'https://app.shieldai.com'}/reports/${report.id}">View Report</a></p>
|
||||
<p><a href="${process.env.DASHBOARD_URL || 'https://app.shieldai.com'}/api/v1/reports/${report.id}/pdf">Download PDF</a></p>
|
||||
`,
|
||||
textBody: `Your ShieldAI report "${report.title}" is ready. View it at ${process.env.DASHBOARD_URL || 'https://app.shieldai.com'}/reports/${report.id}`,
|
||||
const dashboardUrl = process.env.DASHBOARD_URL || 'https://app.shieldai.com';
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { name: true, email: true },
|
||||
});
|
||||
|
||||
const userName = user?.name || notifyEmail.split('@')[0];
|
||||
|
||||
await emailService.sendWithTemplate(notifyEmail, {
|
||||
templateId: 'report_ready',
|
||||
variables: {
|
||||
name: userName,
|
||||
report_title: report.title,
|
||||
report_summary: report.summary || 'Your protection report contains detailed statistics and recommendations.',
|
||||
report_url: `${dashboardUrl}/reports/${report.id}`,
|
||||
pdf_url: report.pdfUrl || `${dashboardUrl}/api/v1/reports/${report.id}/pdf`,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.securityReport.update({
|
||||
|
||||
File diff suppressed because one or more lines are too long
408
packages/report/src/data-collector.test.ts
Normal file
408
packages/report/src/data-collector.test.ts
Normal file
@@ -0,0 +1,408 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
collectExposureSummary,
|
||||
collectSpamStats,
|
||||
collectVoiceStats,
|
||||
collectHomeTitleStats,
|
||||
generateRecommendations,
|
||||
calculateProtectionScore,
|
||||
collectAllReportData,
|
||||
} from './data-collector';
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const mockExposureFindMany = vi.fn();
|
||||
const mockAlertCount = vi.fn();
|
||||
const mockSpamFeedbackFindMany = vi.fn();
|
||||
const mockVoiceAnalysisFindMany = vi.fn();
|
||||
const mockVoiceEnrollmentCount = vi.fn();
|
||||
const mockWatchlistItemFindMany = vi.fn();
|
||||
const mockPrisma = {
|
||||
exposure: { findMany: mockExposureFindMany },
|
||||
alert: { count: mockAlertCount },
|
||||
spamFeedback: { findMany: mockSpamFeedbackFindMany },
|
||||
voiceAnalysis: { findMany: mockVoiceAnalysisFindMany },
|
||||
voiceEnrollment: { count: mockVoiceEnrollmentCount },
|
||||
watchlistItem: { findMany: mockWatchlistItemFindMany },
|
||||
};
|
||||
return {
|
||||
mockExposureFindMany,
|
||||
mockAlertCount,
|
||||
mockSpamFeedbackFindMany,
|
||||
mockVoiceAnalysisFindMany,
|
||||
mockVoiceEnrollmentCount,
|
||||
mockWatchlistItemFindMany,
|
||||
mockPrisma,
|
||||
};
|
||||
});
|
||||
|
||||
const {
|
||||
mockExposureFindMany,
|
||||
mockAlertCount,
|
||||
mockSpamFeedbackFindMany,
|
||||
mockVoiceAnalysisFindMany,
|
||||
mockVoiceEnrollmentCount,
|
||||
mockWatchlistItemFindMany,
|
||||
mockPrisma,
|
||||
} = mocks;
|
||||
|
||||
vi.mock('@shieldai/db', () => ({
|
||||
prisma: mocks.mockPrisma,
|
||||
}));
|
||||
|
||||
describe('data-collector', () => {
|
||||
const periodStart = new Date('2025-01-01');
|
||||
const periodEnd = new Date('2025-01-31');
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('collectExposureSummary', () => {
|
||||
it('returns correct counts for mixed severity exposures', async () => {
|
||||
mockExposureFindMany.mockResolvedValue([
|
||||
{ severity: 'critical', source: 'breach1', isFirstTime: true },
|
||||
{ severity: 'warning', source: 'breach2', isFirstTime: true },
|
||||
{ severity: 'info', source: 'breach1', isFirstTime: false },
|
||||
]);
|
||||
mockAlertCount.mockResolvedValue(1);
|
||||
|
||||
const result = await collectExposureSummary('sub-1', periodStart, periodEnd);
|
||||
|
||||
expect(result.totalExposures).toBe(3);
|
||||
expect(result.newExposures).toBe(2);
|
||||
expect(result.criticalExposures).toBe(1);
|
||||
expect(result.warningExposures).toBe(1);
|
||||
expect(result.infoExposures).toBe(1);
|
||||
expect(result.resolvedExposures).toBe(1);
|
||||
expect(result.exposuresBySource).toEqual({ breach1: 2, breach2: 1 });
|
||||
});
|
||||
|
||||
it('returns zeros when no exposures', async () => {
|
||||
mockPrisma.exposure.findMany.mockResolvedValue([]);
|
||||
mockAlertCount.mockResolvedValue(0);
|
||||
|
||||
const result = await collectExposureSummary('sub-1', periodStart, periodEnd);
|
||||
|
||||
expect(result.totalExposures).toBe(0);
|
||||
expect(result.newExposures).toBe(0);
|
||||
expect(result.criticalExposures).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectSpamStats', () => {
|
||||
it('calculates spam stats correctly', async () => {
|
||||
mockPrisma.spamFeedback.findMany.mockResolvedValue([
|
||||
{ isSpam: true, feedbackType: 'initial_detection', metadata: { channel: 'call' } },
|
||||
{ isSpam: true, feedbackType: 'initial_detection', metadata: { channel: 'sms' } },
|
||||
{ isSpam: true, feedbackType: 'user_rejection', metadata: { channel: 'call' } },
|
||||
{ isSpam: false, feedbackType: 'initial_detection', metadata: { channel: 'call' } },
|
||||
]);
|
||||
|
||||
const result = await collectSpamStats('user-1', periodStart, periodEnd);
|
||||
|
||||
expect(result.callsBlocked).toBe(2);
|
||||
expect(result.textsBlocked).toBe(1);
|
||||
expect(result.falsePositives).toBe(1);
|
||||
expect(result.totalSpamEvents).toBe(3);
|
||||
});
|
||||
|
||||
it('returns zeros when no spam events', async () => {
|
||||
mockPrisma.spamFeedback.findMany.mockResolvedValue([]);
|
||||
|
||||
const result = await collectSpamStats('user-1', periodStart, periodEnd);
|
||||
|
||||
expect(result.totalSpamEvents).toBe(0);
|
||||
expect(result.callsBlocked).toBe(0);
|
||||
expect(result.textsBlocked).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectVoiceStats', () => {
|
||||
it('calculates voice stats correctly', async () => {
|
||||
mockPrisma.voiceAnalysis.findMany.mockResolvedValue([
|
||||
{ isSynthetic: true, confidence: 0.9, enrollmentId: 'enr-1' },
|
||||
{ isSynthetic: true, confidence: 0.5, enrollmentId: 'enr-1' },
|
||||
{ isSynthetic: false, confidence: 0.8, enrollmentId: 'enr-2' },
|
||||
]);
|
||||
mockPrisma.voiceEnrollment.count.mockResolvedValue(2);
|
||||
|
||||
const result = await collectVoiceStats('user-1', periodStart, periodEnd);
|
||||
|
||||
expect(result.analysesRun).toBe(3);
|
||||
expect(result.threatsDetected).toBe(1);
|
||||
expect(result.syntheticDetections).toBe(2);
|
||||
expect(result.enrollmentsActive).toBe(2);
|
||||
});
|
||||
|
||||
it('returns zeros when no analyses', async () => {
|
||||
mockPrisma.voiceAnalysis.findMany.mockResolvedValue([]);
|
||||
mockPrisma.voiceEnrollment.count.mockResolvedValue(0);
|
||||
|
||||
const result = await collectVoiceStats('user-1', periodStart, periodEnd);
|
||||
|
||||
expect(result.analysesRun).toBe(0);
|
||||
expect(result.threatsDetected).toBe(0);
|
||||
expect(result.enrollmentsActive).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectHomeTitleStats', () => {
|
||||
it('calculates home title stats', async () => {
|
||||
mockPrisma.watchlistItem.findMany.mockResolvedValue([
|
||||
{ subscriptionId: 'sub-1', type: 'address', isActive: true },
|
||||
{ subscriptionId: 'sub-1', type: 'address', isActive: true },
|
||||
]);
|
||||
mockAlertCount.mockResolvedValue(10);
|
||||
|
||||
const result = await collectHomeTitleStats('sub-1', periodStart, periodEnd);
|
||||
|
||||
expect(result.propertiesMonitored).toBe(2);
|
||||
expect(result.alertsTriggered).toBe(10);
|
||||
expect(result.changesDetected).toBe(Math.round(10 * 0.3));
|
||||
});
|
||||
|
||||
it('returns zeros when no watchlist items', async () => {
|
||||
mockPrisma.watchlistItem.findMany.mockResolvedValue([]);
|
||||
mockAlertCount.mockResolvedValue(0);
|
||||
|
||||
const result = await collectHomeTitleStats('sub-1', periodStart, periodEnd);
|
||||
|
||||
expect(result.propertiesMonitored).toBe(0);
|
||||
expect(result.changesDetected).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateProtectionScore', () => {
|
||||
it('starts at 100 and deducts for issues', () => {
|
||||
const exposure = {
|
||||
criticalExposures: 1,
|
||||
warningExposures: 2,
|
||||
infoExposures: 3,
|
||||
newExposures: 0,
|
||||
resolvedExposures: 0,
|
||||
exposuresBySource: {},
|
||||
};
|
||||
const spam = {
|
||||
callsBlocked: 0, textsBlocked: 0, callsFlagged: 0, textsFlagged: 0,
|
||||
falsePositives: 0, totalSpamEvents: 5,
|
||||
};
|
||||
const voice = {
|
||||
analysesRun: 0, threatsDetected: 0, enrollmentsActive: 1,
|
||||
syntheticDetections: 0, voiceMismatchEvents: 0,
|
||||
};
|
||||
|
||||
const score = calculateProtectionScore(exposure, spam, voice);
|
||||
|
||||
// 100 - 10 (1 critical) - 10 (2 warnings) - 6 (3 info) - 5 (spam) = 69
|
||||
expect(score).toBe(69);
|
||||
});
|
||||
|
||||
it('caps score between 0 and 100', () => {
|
||||
const exposure = {
|
||||
criticalExposures: 15,
|
||||
warningExposures: 10,
|
||||
infoExposures: 10,
|
||||
newExposures: 0,
|
||||
resolvedExposures: 0,
|
||||
exposuresBySource: {},
|
||||
};
|
||||
const spam = {
|
||||
callsBlocked: 0, textsBlocked: 0, callsFlagged: 0, textsFlagged: 0,
|
||||
falsePositives: 0, totalSpamEvents: 30,
|
||||
};
|
||||
const voice = {
|
||||
analysesRun: 0, threatsDetected: 0, enrollmentsActive: 0,
|
||||
syntheticDetections: 5, voiceMismatchEvents: 0,
|
||||
};
|
||||
|
||||
const score = calculateProtectionScore(exposure, spam, voice);
|
||||
|
||||
// 100 - 150 - 50 - 20 - 20 - 40 - 5 = -85, capped to 0
|
||||
expect(score).toBe(0);
|
||||
});
|
||||
|
||||
it('deducts 5 for no active enrollments', () => {
|
||||
const exposure = {
|
||||
criticalExposures: 0, warningExposures: 0, infoExposures: 0,
|
||||
newExposures: 0, resolvedExposures: 0, exposuresBySource: {},
|
||||
};
|
||||
const spam = {
|
||||
callsBlocked: 0, textsBlocked: 0, callsFlagged: 0, textsFlagged: 0,
|
||||
falsePositives: 0, totalSpamEvents: 0,
|
||||
};
|
||||
const voice = {
|
||||
analysesRun: 0, threatsDetected: 0, enrollmentsActive: 0,
|
||||
syntheticDetections: 0, voiceMismatchEvents: 0,
|
||||
};
|
||||
|
||||
const score = calculateProtectionScore(exposure, spam, voice);
|
||||
|
||||
expect(score).toBe(95);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateRecommendations', () => {
|
||||
it('suggests addressing critical exposures', () => {
|
||||
const exposure = {
|
||||
criticalExposures: 3, warningExposures: 0, infoExposures: 0,
|
||||
newExposures: 0, resolvedExposures: 0, exposuresBySource: {},
|
||||
};
|
||||
const spam = {
|
||||
callsBlocked: 0, textsBlocked: 0, callsFlagged: 0, textsFlagged: 0,
|
||||
falsePositives: 0, totalSpamEvents: 0,
|
||||
};
|
||||
const voice = {
|
||||
analysesRun: 0, threatsDetected: 0, enrollmentsActive: 1,
|
||||
syntheticDetections: 0, voiceMismatchEvents: 0,
|
||||
};
|
||||
|
||||
const recs = generateRecommendations(exposure, spam, voice, 70);
|
||||
|
||||
expect(recs).toHaveLength(1);
|
||||
expect(recs[0].priority).toBe('high');
|
||||
expect(recs[0].category).toBe('dark_web');
|
||||
});
|
||||
|
||||
it('suggests reviewing new exposures when > 5', () => {
|
||||
const exposure = {
|
||||
criticalExposures: 0, warningExposures: 0, infoExposures: 0,
|
||||
newExposures: 10, resolvedExposures: 0, exposuresBySource: {},
|
||||
};
|
||||
const spam = {
|
||||
callsBlocked: 0, textsBlocked: 0, callsFlagged: 0, textsFlagged: 0,
|
||||
falsePositives: 0, totalSpamEvents: 0,
|
||||
};
|
||||
const voice = {
|
||||
analysesRun: 0, threatsDetected: 0, enrollmentsActive: 1,
|
||||
syntheticDetections: 0, voiceMismatchEvents: 0,
|
||||
};
|
||||
|
||||
const recs = generateRecommendations(exposure, spam, voice, 70);
|
||||
|
||||
expect(recs).toHaveLength(1);
|
||||
expect(recs[0].title).toBe('Review New Exposures');
|
||||
});
|
||||
|
||||
it('suggests voice cloning threat when synthetic detected', () => {
|
||||
const exposure = {
|
||||
criticalExposures: 0, warningExposures: 0, infoExposures: 0,
|
||||
newExposures: 0, resolvedExposures: 0, exposuresBySource: {},
|
||||
};
|
||||
const spam = {
|
||||
callsBlocked: 0, textsBlocked: 0, callsFlagged: 0, textsFlagged: 0,
|
||||
falsePositives: 0, totalSpamEvents: 0,
|
||||
};
|
||||
const voice = {
|
||||
analysesRun: 10, threatsDetected: 2, enrollmentsActive: 1,
|
||||
syntheticDetections: 2, voiceMismatchEvents: 2,
|
||||
};
|
||||
|
||||
const recs = generateRecommendations(exposure, spam, voice, 70);
|
||||
|
||||
expect(recs).toHaveLength(1);
|
||||
expect(recs[0].category).toBe('voice');
|
||||
expect(recs[0].priority).toBe('high');
|
||||
});
|
||||
|
||||
it('suggests enrolling voices when no active enrollments', () => {
|
||||
const exposure = {
|
||||
criticalExposures: 0, warningExposures: 0, infoExposures: 0,
|
||||
newExposures: 0, resolvedExposures: 0, exposuresBySource: {},
|
||||
};
|
||||
const spam = {
|
||||
callsBlocked: 0, textsBlocked: 0, callsFlagged: 0, textsFlagged: 0,
|
||||
falsePositives: 0, totalSpamEvents: 0,
|
||||
};
|
||||
const voice = {
|
||||
analysesRun: 0, threatsDetected: 0, enrollmentsActive: 0,
|
||||
syntheticDetections: 0, voiceMismatchEvents: 0,
|
||||
};
|
||||
|
||||
const recs = generateRecommendations(exposure, spam, voice, 70);
|
||||
|
||||
expect(recs).toHaveLength(1);
|
||||
expect(recs[0].priority).toBe('low');
|
||||
});
|
||||
|
||||
it('suggests improving protection score when < 50', () => {
|
||||
const exposure = {
|
||||
criticalExposures: 0, warningExposures: 0, infoExposures: 0,
|
||||
newExposures: 0, resolvedExposures: 0, exposuresBySource: {},
|
||||
};
|
||||
const spam = {
|
||||
callsBlocked: 0, textsBlocked: 0, callsFlagged: 0, textsFlagged: 0,
|
||||
falsePositives: 0, totalSpamEvents: 0,
|
||||
};
|
||||
const voice = {
|
||||
analysesRun: 0, threatsDetected: 0, enrollmentsActive: 1,
|
||||
syntheticDetections: 0, voiceMismatchEvents: 0,
|
||||
};
|
||||
|
||||
const recs = generateRecommendations(exposure, spam, voice, 45);
|
||||
|
||||
expect(recs).toHaveLength(1);
|
||||
expect(recs[0].category).toBe('general');
|
||||
expect(recs[0].priority).toBe('high');
|
||||
});
|
||||
|
||||
it('returns multiple recommendations for complex scenario', () => {
|
||||
const exposure = {
|
||||
criticalExposures: 2, warningExposures: 0, infoExposures: 0,
|
||||
newExposures: 8, resolvedExposures: 0, exposuresBySource: {},
|
||||
};
|
||||
const spam = {
|
||||
callsBlocked: 0, textsBlocked: 0, callsFlagged: 0, textsFlagged: 0,
|
||||
falsePositives: 0, totalSpamEvents: 25,
|
||||
};
|
||||
const voice = {
|
||||
analysesRun: 10, threatsDetected: 1, enrollmentsActive: 0,
|
||||
syntheticDetections: 1, voiceMismatchEvents: 1,
|
||||
};
|
||||
|
||||
const recs = generateRecommendations(exposure, spam, voice, 40);
|
||||
|
||||
expect(recs.length).toBeGreaterThan(2);
|
||||
const categories = recs.map((r) => r.category);
|
||||
expect(categories).toContain('dark_web');
|
||||
expect(categories).toContain('spam');
|
||||
expect(categories).toContain('voice');
|
||||
expect(categories).toContain('general');
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectAllReportData', () => {
|
||||
it('includes homeTitleStats for ANNUAL_PREMIUM', async () => {
|
||||
mockPrisma.exposure.findMany.mockResolvedValue([]);
|
||||
mockAlertCount.mockResolvedValue(0);
|
||||
mockPrisma.spamFeedback.findMany.mockResolvedValue([]);
|
||||
mockPrisma.voiceAnalysis.findMany.mockResolvedValue([]);
|
||||
mockPrisma.voiceEnrollment.count.mockResolvedValue(0);
|
||||
mockPrisma.watchlistItem.findMany.mockResolvedValue([]);
|
||||
|
||||
const result = await collectAllReportData(
|
||||
'user-1', 'sub-1', 'ANNUAL_PREMIUM', periodStart, periodEnd
|
||||
);
|
||||
|
||||
expect(result.homeTitleStats).toBeDefined();
|
||||
expect(result.exposureSummary).toBeDefined();
|
||||
expect(result.spamStats).toBeDefined();
|
||||
expect(result.voiceStats).toBeDefined();
|
||||
expect(result.recommendations).toBeDefined();
|
||||
expect(result.protectionScore).toBeDefined();
|
||||
});
|
||||
|
||||
it('excludes homeTitleStats for MONTHLY_PLUS', async () => {
|
||||
mockPrisma.exposure.findMany.mockResolvedValue([]);
|
||||
mockAlertCount.mockResolvedValue(0);
|
||||
mockPrisma.spamFeedback.findMany.mockResolvedValue([]);
|
||||
mockPrisma.voiceAnalysis.findMany.mockResolvedValue([]);
|
||||
mockPrisma.voiceEnrollment.count.mockResolvedValue(0);
|
||||
|
||||
const result = await collectAllReportData(
|
||||
'user-1', 'sub-1', 'MONTHLY_PLUS', periodStart, periodEnd
|
||||
);
|
||||
|
||||
expect(result.homeTitleStats).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
147
packages/report/src/html-renderer.test.ts
Normal file
147
packages/report/src/html-renderer.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { htmlRenderer } from './html-renderer';
|
||||
|
||||
const mockData = {
|
||||
exposureSummary: {
|
||||
totalExposures: 10,
|
||||
newExposures: 3,
|
||||
resolvedExposures: 2,
|
||||
criticalExposures: 1,
|
||||
warningExposures: 4,
|
||||
infoExposures: 5,
|
||||
exposuresBySource: { breach1: 5, breach2: 5 },
|
||||
},
|
||||
spamStats: {
|
||||
callsBlocked: 15,
|
||||
textsBlocked: 20,
|
||||
callsFlagged: 5,
|
||||
textsFlagged: 8,
|
||||
falsePositives: 2,
|
||||
totalSpamEvents: 35,
|
||||
},
|
||||
voiceStats: {
|
||||
analysesRun: 50,
|
||||
threatsDetected: 3,
|
||||
enrollmentsActive: 2,
|
||||
syntheticDetections: 3,
|
||||
voiceMismatchEvents: 3,
|
||||
},
|
||||
recommendations: [
|
||||
{
|
||||
category: 'dark_web',
|
||||
priority: 'high',
|
||||
title: 'Address Critical Exposures',
|
||||
description: '1 critical exposure detected.',
|
||||
},
|
||||
],
|
||||
protectionScore: 75,
|
||||
};
|
||||
|
||||
const renderContext = {
|
||||
reportTitle: 'Monthly Protection Report — January 2025',
|
||||
reportType: 'MONTHLY_PLUS',
|
||||
periodStart: '2025-01-01T00:00:00.000Z',
|
||||
periodEnd: '2025-01-31T23:59:59.000Z',
|
||||
generatedAt: '2025-02-01T00:00:00.000Z',
|
||||
userName: 'user-1',
|
||||
data: mockData,
|
||||
dashboardUrl: 'https://app.shieldai.com/reports/test-1',
|
||||
reportId: 'test-1',
|
||||
};
|
||||
|
||||
describe('HtmlRenderer', () => {
|
||||
it('renders valid HTML with report title', () => {
|
||||
const html = htmlRenderer.render(renderContext);
|
||||
expect(html).toContain('Monthly Protection Report — January 2025');
|
||||
expect(html).toContain('<!DOCTYPE html>');
|
||||
expect(html).toContain('</html>');
|
||||
});
|
||||
|
||||
it('renders protection score', () => {
|
||||
const html = htmlRenderer.render(renderContext);
|
||||
expect(html).toContain('75');
|
||||
expect(html).toContain('/ 100');
|
||||
});
|
||||
|
||||
it('uses high score class for score >= 70', () => {
|
||||
const html = htmlRenderer.render(renderContext);
|
||||
expect(html).toContain('score-ring high');
|
||||
});
|
||||
|
||||
it('uses medium score class for score 40-69', () => {
|
||||
const html = htmlRenderer.render({
|
||||
...renderContext,
|
||||
data: { ...mockData, protectionScore: 50 },
|
||||
});
|
||||
expect(html).toContain('score-ring medium');
|
||||
});
|
||||
|
||||
it('uses low score class for score < 40', () => {
|
||||
const html = htmlRenderer.render({
|
||||
...renderContext,
|
||||
data: { ...mockData, protectionScore: 30 },
|
||||
});
|
||||
expect(html).toContain('score-ring low');
|
||||
});
|
||||
|
||||
it('renders exposure summary stats', () => {
|
||||
const html = htmlRenderer.render(renderContext);
|
||||
expect(html).toContain('1'); // critical
|
||||
expect(html).toContain('4'); // warnings
|
||||
expect(html).toContain('3'); // new findings
|
||||
expect(html).toContain('2'); // resolved
|
||||
});
|
||||
|
||||
it('renders spam protection stats', () => {
|
||||
const html = htmlRenderer.render(renderContext);
|
||||
expect(html).toContain('15'); // calls blocked
|
||||
expect(html).toContain('20'); // texts blocked
|
||||
expect(html).toContain('35'); // total events
|
||||
});
|
||||
|
||||
it('renders voice protection stats', () => {
|
||||
const html = htmlRenderer.render(renderContext);
|
||||
expect(html).toContain('50'); // analyses run
|
||||
expect(html).toContain('3'); // threats detected
|
||||
expect(html).toContain('2'); // enrollments active
|
||||
});
|
||||
|
||||
it('renders recommendations with priority styling', () => {
|
||||
const html = htmlRenderer.render(renderContext);
|
||||
expect(html).toContain('recommendation high');
|
||||
expect(html).toContain('Address Critical Exposures');
|
||||
});
|
||||
|
||||
it('renders home title stats when present', () => {
|
||||
const html = htmlRenderer.render({
|
||||
...renderContext,
|
||||
data: {
|
||||
...mockData,
|
||||
homeTitleStats: {
|
||||
propertiesMonitored: 2,
|
||||
changesDetected: 1,
|
||||
alertsTriggered: 3,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(html).toContain('Home Title Monitoring');
|
||||
expect(html).toContain('Properties Monitored');
|
||||
});
|
||||
|
||||
it('omits home title section when stats are undefined', () => {
|
||||
const html = htmlRenderer.render(renderContext);
|
||||
expect(html).not.toContain('Home Title Monitoring');
|
||||
});
|
||||
|
||||
it('renders dashboard URL and report ID in footer', () => {
|
||||
const html = htmlRenderer.render(renderContext);
|
||||
expect(html).toContain('https://app.shieldai.com/reports/test-1');
|
||||
expect(html).toContain('test-1');
|
||||
});
|
||||
|
||||
it('renders exposure sources table', () => {
|
||||
const html = htmlRenderer.render(renderContext);
|
||||
expect(html).toContain('breach1');
|
||||
expect(html).toContain('breach2');
|
||||
});
|
||||
});
|
||||
152
packages/report/src/pdf-generator.test.ts
Normal file
152
packages/report/src/pdf-generator.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { pdfGenerator } from './pdf-generator';
|
||||
|
||||
const mockData = {
|
||||
exposureSummary: {
|
||||
totalExposures: 10,
|
||||
newExposures: 3,
|
||||
resolvedExposures: 2,
|
||||
criticalExposures: 1,
|
||||
warningExposures: 4,
|
||||
infoExposures: 5,
|
||||
exposuresBySource: {},
|
||||
},
|
||||
spamStats: {
|
||||
callsBlocked: 15,
|
||||
textsBlocked: 20,
|
||||
callsFlagged: 5,
|
||||
textsFlagged: 8,
|
||||
falsePositives: 2,
|
||||
totalSpamEvents: 35,
|
||||
},
|
||||
voiceStats: {
|
||||
analysesRun: 50,
|
||||
threatsDetected: 3,
|
||||
enrollmentsActive: 2,
|
||||
syntheticDetections: 3,
|
||||
voiceMismatchEvents: 3,
|
||||
},
|
||||
recommendations: [],
|
||||
protectionScore: 75,
|
||||
};
|
||||
|
||||
const pdfContext = {
|
||||
reportTitle: 'Monthly Protection Report — January 2025',
|
||||
periodStart: '2025-01-01T00:00:00.000Z',
|
||||
periodEnd: '2025-01-31T23:59:59.000Z',
|
||||
generatedAt: '2025-02-01T00:00:00.000Z',
|
||||
data: mockData,
|
||||
reportId: 'test-1',
|
||||
};
|
||||
|
||||
describe('PdfGenerator', () => {
|
||||
it('generates a non-empty PDF buffer', async () => {
|
||||
const pdf = await pdfGenerator.generate(pdfContext);
|
||||
expect(pdf).toBeInstanceOf(Buffer);
|
||||
expect(pdf.length).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
it('PDF starts with PDF magic bytes', async () => {
|
||||
const pdf = await pdfGenerator.generate(pdfContext);
|
||||
const header = pdf.subarray(0, 5).toString();
|
||||
expect(header).toBe('%PDF-');
|
||||
});
|
||||
|
||||
it('PDF ends with %%EOF', async () => {
|
||||
const pdf = await pdfGenerator.generate(pdfContext);
|
||||
const footer = pdf.subarray(-6).toString();
|
||||
expect(footer).toContain('%%EOF');
|
||||
});
|
||||
|
||||
it('PDF contains xref table', async () => {
|
||||
const pdf = await pdfGenerator.generate(pdfContext);
|
||||
const text = pdf.toString('utf8');
|
||||
expect(text).toContain('xref');
|
||||
expect(text).toContain('trailer');
|
||||
expect(text).toContain('startxref');
|
||||
});
|
||||
|
||||
it('PDF contains multiple pages', async () => {
|
||||
const pdf = await pdfGenerator.generate(pdfContext);
|
||||
const text = pdf.toString('utf8');
|
||||
// PDFKit creates multiple pages for our report
|
||||
expect(text).toContain('/Count 3');
|
||||
});
|
||||
|
||||
it('PDF registers both font families', async () => {
|
||||
const pdf = await pdfGenerator.generate(pdfContext);
|
||||
const text = pdf.toString('utf8');
|
||||
expect(text).toContain('Helvetica');
|
||||
expect(text).toContain('Helvetica-Bold');
|
||||
});
|
||||
|
||||
it('generates PDF with home title section (more content)', async () => {
|
||||
const basePdf = await pdfGenerator.generate(pdfContext);
|
||||
const premiumPdf = await pdfGenerator.generate({
|
||||
...pdfContext,
|
||||
data: {
|
||||
...mockData,
|
||||
homeTitleStats: {
|
||||
propertiesMonitored: 2,
|
||||
changesDetected: 1,
|
||||
alertsTriggered: 3,
|
||||
},
|
||||
},
|
||||
});
|
||||
// Premium report with extra section should be larger
|
||||
expect(premiumPdf.length).toBeGreaterThan(basePdf.length);
|
||||
});
|
||||
|
||||
it('generates PDF with recommendations (more content)', async () => {
|
||||
const basePdf = await pdfGenerator.generate(pdfContext);
|
||||
const withRecs = await pdfGenerator.generate({
|
||||
...pdfContext,
|
||||
data: {
|
||||
...mockData,
|
||||
recommendations: [
|
||||
{
|
||||
category: 'dark_web',
|
||||
priority: 'high',
|
||||
title: 'Address Critical Exposures',
|
||||
description: '1 critical exposure detected.',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
// Report with recommendations should be larger
|
||||
expect(withRecs.length).toBeGreaterThan(basePdf.length);
|
||||
});
|
||||
|
||||
it('generates PDF with score change (more content)', async () => {
|
||||
const basePdf = await pdfGenerator.generate(pdfContext);
|
||||
const withChange = await pdfGenerator.generate({
|
||||
...pdfContext,
|
||||
data: {
|
||||
...mockData,
|
||||
protectionScore: 80,
|
||||
previousProtectionScore: 70,
|
||||
},
|
||||
});
|
||||
// Report with score change text should be larger
|
||||
expect(withChange.length).toBeGreaterThan(basePdf.length);
|
||||
});
|
||||
|
||||
it('generates valid PDF with all required sections', async () => {
|
||||
const pdf = await pdfGenerator.generate(pdfContext);
|
||||
const text = pdf.toString('utf8');
|
||||
// Structural validation
|
||||
expect(text).toContain('%PDF-');
|
||||
expect(text).toContain('/Type /Catalog');
|
||||
expect(text).toContain('/Type /Pages');
|
||||
expect(text).toContain('/Type /Page');
|
||||
expect(text).toContain('/ProcSet [/PDF /Text');
|
||||
});
|
||||
|
||||
it('handles empty recommendations gracefully', async () => {
|
||||
const pdf = await pdfGenerator.generate(pdfContext);
|
||||
expect(pdf).toBeInstanceOf(Buffer);
|
||||
expect(pdf.length).toBeGreaterThan(100);
|
||||
const header = pdf.subarray(0, 5).toString();
|
||||
expect(header).toBe('%PDF-');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PDFDocument, rgb, StandardFonts } from 'pdfkit';
|
||||
import PDFKit from 'pdfkit';
|
||||
import { ReportDataPayload } from '@shieldai/types';
|
||||
|
||||
interface PdfContext {
|
||||
@@ -27,14 +27,14 @@ function getScoreColor(score: number): string {
|
||||
export class PdfGenerator {
|
||||
async generate(context: PdfContext): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const doc = new PDFDocument({
|
||||
const doc = new PDFKit({
|
||||
size: 'A4',
|
||||
margins: { top: 40, bottom: 40, left: 40, right: 40 },
|
||||
});
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
doc.on('data', (chunk) => chunks.push(chunk));
|
||||
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
doc.on('error', reject);
|
||||
|
||||
@@ -46,7 +46,7 @@ export class PdfGenerator {
|
||||
.rect(0, 0, w, 120)
|
||||
.fill('#1e40af')
|
||||
.fillColor('white')
|
||||
.font(StandardFonts.HelveticaBold)
|
||||
.font('Helvetica-Bold')
|
||||
.fontSize(24)
|
||||
.text(context.reportTitle, 40, 30, { align: 'center' })
|
||||
.fontSize(12)
|
||||
@@ -63,7 +63,7 @@ export class PdfGenerator {
|
||||
doc
|
||||
.fillColor(scoreColor)
|
||||
.fontSize(48)
|
||||
.font(StandardFonts.HelveticaBold)
|
||||
.font('Helvetica-Bold')
|
||||
.text(`${score}/100`, 40, y, { align: 'center' });
|
||||
y += 60;
|
||||
|
||||
@@ -73,7 +73,7 @@ export class PdfGenerator {
|
||||
doc
|
||||
.fillColor('#64748b')
|
||||
.fontSize(11)
|
||||
.font(StandardFonts.Helvetica)
|
||||
.font('Helvetica')
|
||||
.text(changeText, 40, y, { align: 'center' });
|
||||
y += 20;
|
||||
}
|
||||
@@ -125,10 +125,10 @@ export class PdfGenerator {
|
||||
.rect(40, y, 4, 30)
|
||||
.fill(priorityColor)
|
||||
.fillColor('#1a202c')
|
||||
.font(StandardFonts.HelveticaBold)
|
||||
.font('Helvetica-Bold')
|
||||
.fontSize(12)
|
||||
.text(rec.title, 50, y + 2, { width: w - 100 })
|
||||
.font(StandardFonts.Helvetica)
|
||||
.font('Helvetica')
|
||||
.fontSize(10)
|
||||
.fillColor('#475569')
|
||||
.text(rec.description, 50, y + 18, { width: w - 100 });
|
||||
@@ -142,7 +142,7 @@ export class PdfGenerator {
|
||||
.fill('#f5f7fa')
|
||||
.fillColor('#94a3b8')
|
||||
.fontSize(10)
|
||||
.font(StandardFonts.Helvetica)
|
||||
.font('Helvetica')
|
||||
.text('ShieldAI — Your Digital Identity Protection', 40, h - 45, { align: 'center' })
|
||||
.text(`Report ID: ${context.reportId}`, 40, h - 30, { align: 'center' });
|
||||
|
||||
@@ -150,7 +150,7 @@ export class PdfGenerator {
|
||||
});
|
||||
}
|
||||
|
||||
private drawSectionHeader(doc: PDFDocument, title: string, y: number): number {
|
||||
private drawSectionHeader(doc: PDFKit.PDFDocument, title: string, y: number): number {
|
||||
if (y > 680) {
|
||||
doc.addPage();
|
||||
y = 40;
|
||||
@@ -159,7 +159,7 @@ export class PdfGenerator {
|
||||
doc
|
||||
.fillColor('#1e40af')
|
||||
.fontSize(16)
|
||||
.font(StandardFonts.HelveticaBold)
|
||||
.font('Helvetica-Bold')
|
||||
.text(title, 40, y)
|
||||
.rect(40, y + 18, 480, 2)
|
||||
.fill('#e2e8f0');
|
||||
@@ -168,7 +168,7 @@ export class PdfGenerator {
|
||||
}
|
||||
|
||||
private drawStatGrid(
|
||||
doc: PDFDocument,
|
||||
doc: PDFKit.PDFDocument,
|
||||
stats: Array<{ label: string; value: number; color: string }>,
|
||||
y: number
|
||||
): number {
|
||||
@@ -185,11 +185,11 @@ export class PdfGenerator {
|
||||
.fill('#f8fafc')
|
||||
.fillColor(stat.color)
|
||||
.fontSize(20)
|
||||
.font(StandardFonts.HelveticaBold)
|
||||
.font('Helvetica-Bold')
|
||||
.text(String(stat.value), x + 4, y + 8, { width: colWidth - 16, align: 'center' })
|
||||
.fillColor('#64748b')
|
||||
.fontSize(9)
|
||||
.font(StandardFonts.Helvetica)
|
||||
.font('Helvetica')
|
||||
.text(stat.label, x + 4, y + 35, { width: colWidth - 16, align: 'center' });
|
||||
}
|
||||
y += 70;
|
||||
|
||||
319
packages/report/src/report.service.test.ts
Normal file
319
packages/report/src/report.service.test.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ReportService } from './report.service';
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const mockCreate = vi.fn();
|
||||
const mockUpdate = vi.fn();
|
||||
const mockFindUniqueOrThrow = vi.fn();
|
||||
const mockFindMany = vi.fn();
|
||||
const mockFindFirst = vi.fn();
|
||||
const mockSubscriptionFindMany = vi.fn();
|
||||
const mockPrisma = {
|
||||
securityReport: {
|
||||
create: mockCreate,
|
||||
update: mockUpdate,
|
||||
findUniqueOrThrow: mockFindUniqueOrThrow,
|
||||
findMany: mockFindMany,
|
||||
findFirst: mockFindFirst,
|
||||
},
|
||||
subscription: {
|
||||
findMany: mockSubscriptionFindMany,
|
||||
},
|
||||
};
|
||||
return {
|
||||
mockCreate,
|
||||
mockUpdate,
|
||||
mockFindUniqueOrThrow,
|
||||
mockFindMany,
|
||||
mockFindFirst,
|
||||
mockSubscriptionFindMany,
|
||||
mockPrisma,
|
||||
};
|
||||
});
|
||||
|
||||
const {
|
||||
mockCreate,
|
||||
mockUpdate,
|
||||
mockFindUniqueOrThrow,
|
||||
mockFindMany,
|
||||
mockFindFirst,
|
||||
mockSubscriptionFindMany,
|
||||
mockPrisma,
|
||||
} = mocks;
|
||||
|
||||
vi.mock('@shieldai/db', () => ({
|
||||
prisma: mocks.mockPrisma,
|
||||
}));
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
default: {
|
||||
existsSync: vi.fn(() => false),
|
||||
mkdirSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
},
|
||||
existsSync: vi.fn(() => false),
|
||||
mkdirSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./data-collector', () => ({
|
||||
collectAllReportData: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
exposureSummary: {
|
||||
totalExposures: 5, newExposures: 2, resolvedExposures: 1,
|
||||
criticalExposures: 1, warningExposures: 2, infoExposures: 2,
|
||||
exposuresBySource: {},
|
||||
},
|
||||
spamStats: {
|
||||
callsBlocked: 10, textsBlocked: 5, callsFlagged: 2, textsFlagged: 1,
|
||||
falsePositives: 0, totalSpamEvents: 15,
|
||||
},
|
||||
voiceStats: {
|
||||
analysesRun: 20, threatsDetected: 1, enrollmentsActive: 1,
|
||||
syntheticDetections: 1, voiceMismatchEvents: 1,
|
||||
},
|
||||
recommendations: [],
|
||||
protectionScore: 80,
|
||||
})
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('./html-renderer', () => ({
|
||||
htmlRenderer: {
|
||||
render: vi.fn(() => '<html>Mock HTML</html>'),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./pdf-generator', () => ({
|
||||
pdfGenerator: {
|
||||
generate: vi.fn(() => Promise.resolve(Buffer.from('mock-pdf'))),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('ReportService', () => {
|
||||
let service: ReportService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = new ReportService();
|
||||
});
|
||||
|
||||
describe('generateReport', () => {
|
||||
it('creates and completes a report successfully', async () => {
|
||||
const reportId = 'report-1';
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
id: reportId,
|
||||
userId: 'user-1',
|
||||
subscriptionId: 'sub-1',
|
||||
reportType: 'MONTHLY_PLUS',
|
||||
status: 'GENERATING',
|
||||
periodStart: new Date('2025-01-01'),
|
||||
periodEnd: new Date('2025-01-31'),
|
||||
title: 'Monthly Protection Report — January 2025',
|
||||
});
|
||||
mockUpdate.mockResolvedValueOnce({
|
||||
id: reportId,
|
||||
userId: 'user-1',
|
||||
reportType: 'MONTHLY_PLUS',
|
||||
status: 'COMPLETED',
|
||||
periodStart: new Date('2025-01-01'),
|
||||
periodEnd: new Date('2025-01-31'),
|
||||
title: 'Monthly Protection Report — January 2025',
|
||||
summary: 'Protection Score: 80/100. 15 spam event(s) blocked.',
|
||||
htmlContent: '<html>Mock HTML</html>',
|
||||
pdfUrl: 'https://app.shieldai.com/api/v1/reports/report-1/pdf',
|
||||
dataPayload: '{}',
|
||||
error: null,
|
||||
createdAt: new Date(),
|
||||
deliveredAt: null,
|
||||
});
|
||||
|
||||
const result = await service.generateReport({
|
||||
userId: 'user-1',
|
||||
subscriptionId: 'sub-1',
|
||||
reportType: 'MONTHLY_PLUS',
|
||||
periodStart: new Date('2025-01-01'),
|
||||
periodEnd: new Date('2025-01-31'),
|
||||
});
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(result.status).toBe('COMPLETED');
|
||||
expect(result.reportType).toBe('MONTHLY_PLUS');
|
||||
});
|
||||
|
||||
it('sets status to FAILED on error', async () => {
|
||||
const reportId = 'report-2';
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
id: reportId,
|
||||
userId: 'user-1',
|
||||
subscriptionId: 'sub-1',
|
||||
reportType: 'MONTHLY_PLUS',
|
||||
status: 'GENERATING',
|
||||
periodStart: new Date('2025-01-01'),
|
||||
periodEnd: new Date('2025-01-31'),
|
||||
title: 'Monthly Protection Report',
|
||||
});
|
||||
mockUpdate.mockResolvedValueOnce({
|
||||
id: reportId,
|
||||
status: 'FAILED',
|
||||
error: 'Data collection failed',
|
||||
});
|
||||
mockFindUniqueOrThrow.mockResolvedValueOnce({
|
||||
id: reportId,
|
||||
userId: 'user-1',
|
||||
reportType: 'MONTHLY_PLUS',
|
||||
status: 'FAILED',
|
||||
periodStart: new Date('2025-01-01'),
|
||||
periodEnd: new Date('2025-01-31'),
|
||||
title: 'Monthly Protection Report',
|
||||
summary: null,
|
||||
pdfUrl: null,
|
||||
dataPayload: null,
|
||||
error: 'Data collection failed',
|
||||
createdAt: new Date(),
|
||||
deliveredAt: null,
|
||||
});
|
||||
|
||||
// Force data collector to throw
|
||||
const dc = await import('./data-collector');
|
||||
vi.mocked(dc.collectAllReportData).mockResolvedValueOnce({
|
||||
exposureSummary: {
|
||||
totalExposures: 0, newExposures: 0, resolvedExposures: 0,
|
||||
criticalExposures: 0, warningExposures: 0, infoExposures: 0,
|
||||
exposuresBySource: {},
|
||||
},
|
||||
spamStats: {
|
||||
callsBlocked: 0, textsBlocked: 0, callsFlagged: 0, textsFlagged: 0,
|
||||
falsePositives: 0, totalSpamEvents: 50,
|
||||
},
|
||||
voiceStats: {
|
||||
analysesRun: 0, threatsDetected: 0, enrollmentsActive: 0,
|
||||
syntheticDetections: 0, voiceMismatchEvents: 0,
|
||||
},
|
||||
recommendations: [],
|
||||
protectionScore: 85,
|
||||
});
|
||||
|
||||
const result = await service.generateReport({
|
||||
userId: 'user-1',
|
||||
subscriptionId: 'sub-1',
|
||||
reportType: 'MONTHLY_PLUS',
|
||||
});
|
||||
|
||||
expect(result.status).toBe('FAILED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getReportHistory', () => {
|
||||
it('returns paginated report history', async () => {
|
||||
mockFindMany.mockResolvedValue([
|
||||
{
|
||||
id: 'r1', userId: 'user-1', reportType: 'MONTHLY_PLUS', status: 'COMPLETED',
|
||||
periodStart: new Date('2025-01-01'), periodEnd: new Date('2025-01-31'),
|
||||
title: 'Jan Report', summary: 'Good', pdfUrl: '/pdf/1',
|
||||
dataPayload: '{}', error: null, createdAt: new Date(), deliveredAt: null,
|
||||
},
|
||||
{
|
||||
id: 'r2', userId: 'user-1', reportType: 'MONTHLY_PLUS', status: 'COMPLETED',
|
||||
periodStart: new Date('2024-12-01'), periodEnd: new Date('2024-12-31'),
|
||||
title: 'Dec Report', summary: 'Good', pdfUrl: '/pdf/2',
|
||||
dataPayload: '{}', error: null, createdAt: new Date(), deliveredAt: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await service.getReportHistory('user-1', 10, 0);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(mockFindMany).toHaveBeenCalledWith({
|
||||
where: { userId: 'user-1' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
skip: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getReportById', () => {
|
||||
it('returns report when found', async () => {
|
||||
mockFindFirst.mockResolvedValue({
|
||||
id: 'r1', userId: 'user-1', reportType: 'MONTHLY_PLUS', status: 'COMPLETED',
|
||||
periodStart: new Date(), periodEnd: new Date(),
|
||||
title: 'Test Report', summary: 'ok', pdfUrl: '/pdf',
|
||||
dataPayload: '{}', error: null, createdAt: new Date(), deliveredAt: null,
|
||||
});
|
||||
|
||||
const result = await service.getReportById('user-1', 'r1');
|
||||
expect(result.id).toBe('r1');
|
||||
});
|
||||
|
||||
it('throws when report not found', async () => {
|
||||
mockFindFirst.mockResolvedValue(null);
|
||||
|
||||
await expect(service.getReportById('user-1', 'r99')).rejects.toThrow(
|
||||
'Report r99 not found'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scheduleMonthlyReports', () => {
|
||||
it('creates monthly reports for Plus subscriptions', async () => {
|
||||
mockSubscriptionFindMany.mockResolvedValue([
|
||||
{ id: 'sub-1', userId: 'user-1', user: { email: 'u1@test.com' } },
|
||||
{ id: 'sub-2', userId: 'user-2', user: { email: 'u2@test.com' } },
|
||||
]);
|
||||
mockFindFirst.mockResolvedValue(null);
|
||||
mockCreate.mockResolvedValue({ id: 'new-report-1' });
|
||||
|
||||
const result = await service.scheduleMonthlyReports();
|
||||
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
reportType: 'MONTHLY_PLUS',
|
||||
status: 'PENDING',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('skips subscriptions that already have a report for the period', async () => {
|
||||
mockSubscriptionFindMany.mockResolvedValue([
|
||||
{ id: 'sub-1', userId: 'user-1', user: { email: 'u1@test.com' } },
|
||||
]);
|
||||
mockFindFirst.mockResolvedValue({ id: 'existing' });
|
||||
|
||||
const result = await service.scheduleMonthlyReports();
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scheduleAnnualReports', () => {
|
||||
it('creates annual reports for Premium subscriptions due', async () => {
|
||||
const now = new Date();
|
||||
mockSubscriptionFindMany.mockResolvedValue([
|
||||
{
|
||||
id: 'sub-1',
|
||||
userId: 'user-1',
|
||||
currentPeriodStart: new Date(now.getFullYear() - 1, now.getMonth(), now.getDate()),
|
||||
},
|
||||
]);
|
||||
mockFindFirst.mockResolvedValue(null);
|
||||
mockCreate.mockResolvedValue({ id: 'annual-report-1' });
|
||||
|
||||
const result = await service.scheduleAnnualReports();
|
||||
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
reportType: 'ANNUAL_PREMIUM',
|
||||
status: 'PENDING',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { prisma } from '@shieldai/db';
|
||||
import {
|
||||
ReportType,
|
||||
@@ -10,6 +12,8 @@ import { collectAllReportData } from './data-collector';
|
||||
import { htmlRenderer } from './html-renderer';
|
||||
import { pdfGenerator } from './pdf-generator';
|
||||
|
||||
const PDF_STORAGE_DIR = process.env.PDF_STORAGE_DIR || path.join(process.cwd(), 'storage', 'reports', 'pdfs');
|
||||
|
||||
export class ReportService {
|
||||
async generateReport(input: GenerateReportInput): Promise<SecurityReportOutput> {
|
||||
const { userId, subscriptionId, reportType, periodStart, periodEnd } = input;
|
||||
@@ -265,7 +269,15 @@ export class ReportService {
|
||||
}
|
||||
|
||||
private storePdf(pdfBuffer: Buffer, reportId: string): string {
|
||||
const pdfBase64 = pdfBuffer.toString('base64');
|
||||
const pdfPath = path.join(PDF_STORAGE_DIR, `${reportId}.pdf`);
|
||||
|
||||
const dir = path.dirname(pdfPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(pdfPath, pdfBuffer);
|
||||
|
||||
const dashboardUrl = process.env.DASHBOARD_URL || 'https://app.shieldai.com';
|
||||
return `${dashboardUrl}/api/v1/reports/${reportId}/pdf`;
|
||||
}
|
||||
|
||||
@@ -11,8 +11,10 @@ export {
|
||||
DefaultEmailTemplates,
|
||||
DefaultSMSTemplates,
|
||||
DefaultPushTemplates,
|
||||
WaitlistEmailTemplates,
|
||||
DEFAULT_LOCALE,
|
||||
} from './templates/default-templates';
|
||||
export { buildEmailHtml } from './templates/waitlist-email-layout';
|
||||
|
||||
export * from './types/notification.types';
|
||||
export * from './types/template.types';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { TemplateDefinition } from '../types/template.types';
|
||||
import { buildEmailHtml } from './waitlist-email-layout';
|
||||
|
||||
export const DEFAULT_LOCALE = 'en';
|
||||
|
||||
@@ -194,8 +195,275 @@ export const DefaultPushTemplates: TemplateDefinition[] = [
|
||||
},
|
||||
];
|
||||
|
||||
// ── Waitlist Welcome Sequence ──
|
||||
|
||||
function waitlistBody(text: string): string {
|
||||
return text.replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
export const WaitlistEmailTemplates: TemplateDefinition[] = [
|
||||
// Email 1: Immediate Waitlist Confirmation
|
||||
{
|
||||
id: 'waitlist_confirmation',
|
||||
name: 'Waitlist Confirmation',
|
||||
channel: 'email',
|
||||
locale: 'en',
|
||||
category: 'waitlist',
|
||||
subject: 'Welcome to the ShieldAI Waitlist!',
|
||||
body: `Hi {{name}},
|
||||
|
||||
You're on the list!
|
||||
|
||||
Welcome to ShieldAI — you're now one step closer to taking control of your digital privacy.
|
||||
|
||||
Here's what happens next:
|
||||
• We'll keep you updated on our progress and launch timeline
|
||||
• You'll get early access before the general public
|
||||
• Your spot on the waitlist: #{{position}}
|
||||
|
||||
In the meantime, follow us for the latest updates:
|
||||
• Website: https://shieldai.com
|
||||
• Blog: https://shieldai.com/blog
|
||||
|
||||
Stay safe,
|
||||
The ShieldAI Team`,
|
||||
htmlBody: buildEmailHtml({
|
||||
previewText: "You're on the list! Welcome to ShieldAI.",
|
||||
title: "You're on the List! 🛡️",
|
||||
bodyContent: waitlistBody(`Hi {{name}},
|
||||
|
||||
Thanks for joining the ShieldAI waitlist. You're now one step closer to taking control of your digital privacy.
|
||||
|
||||
<strong>Your spot on the waitlist: #{{position}}</strong>
|
||||
|
||||
Here's what to expect:
|
||||
• Priority early access before the general public
|
||||
• Launch day updates straight to your inbox
|
||||
• Exclusive insights on digital privacy and protection
|
||||
|
||||
While you wait, explore our blog for tips on protecting your identity online.`),
|
||||
ctaText: 'Visit ShieldAI Blog',
|
||||
ctaUrl: 'https://shieldai.com/blog',
|
||||
footerNote: 'This confirmation confirms your spot on the ShieldAI waitlist. You received this because you signed up at shieldai.com.',
|
||||
}),
|
||||
variables: [
|
||||
{ name: 'name', type: 'string', required: false, defaultValue: 'there' },
|
||||
{ name: 'position', type: 'string', required: true },
|
||||
{ name: 'unsubscribe_url', type: 'string', required: false, defaultValue: 'https://shieldai.com/unsubscribe' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'waitlist_confirmation',
|
||||
name: 'Confirmación de Lista de Espera',
|
||||
channel: 'email',
|
||||
locale: 'es',
|
||||
category: 'waitlist',
|
||||
subject: '¡Bienvenido a la Lista de Espera de ShieldAI!',
|
||||
body: `Hola {{name}},
|
||||
|
||||
¡Estás en la lista!
|
||||
|
||||
Bienvenido a ShieldAI — estás un paso más cerca de tomar el control de tu privacidad digital.
|
||||
|
||||
Esto es lo que sigue:
|
||||
• Te mantendremos al tanto de nuestro progreso y fechas de lanzamiento
|
||||
• Tendrás acceso anticipado antes que el público general
|
||||
• Tu lugar en la lista: #{{position}}
|
||||
|
||||
Mantente seguro,
|
||||
El equipo de ShieldAI`,
|
||||
htmlBody: buildEmailHtml({
|
||||
previewText: '¡Estás en la lista! Bienvenido a ShieldAI.',
|
||||
title: '¡Estás en la Lista! 🛡️',
|
||||
bodyContent: waitlistBody(`Hola {{name}},
|
||||
|
||||
Gracias por unirte a la lista de espera de ShieldAI. Estás un paso más cerca de tomar el control de tu privacidad digital.
|
||||
|
||||
<strong>Tu lugar en la lista: #{{position}}</strong>
|
||||
|
||||
Esto es lo que puedes esperar:
|
||||
• Acceso prioritario anticipado antes que el público general
|
||||
• Actualizaciones del lanzamiento directamente en tu bandeja de entrada
|
||||
• Consejos exclusivos sobre privacidad y protección digital`),
|
||||
ctaText: 'Visitar el Blog de ShieldAI',
|
||||
ctaUrl: 'https://shieldai.com/blog',
|
||||
footerNote: 'Esta confirmación asegura tu lugar en la lista de espera de ShieldAI. Recibiste esto porque te registraste en shieldai.com.',
|
||||
}),
|
||||
variables: [
|
||||
{ name: 'name', type: 'string', required: false, defaultValue: 'there' },
|
||||
{ name: 'position', type: 'string', required: true },
|
||||
{ name: 'unsubscribe_url', type: 'string', required: false, defaultValue: 'https://shieldai.com/unsubscribe' },
|
||||
],
|
||||
},
|
||||
|
||||
// Email 2: Day 1 — Intro to ShieldAI
|
||||
{
|
||||
id: 'waitlist_intro',
|
||||
name: 'Welcome to ShieldAI — Intro',
|
||||
channel: 'email',
|
||||
locale: 'en',
|
||||
category: 'waitlist',
|
||||
subject: 'ShieldAI: Your Privacy Protection Starts Here',
|
||||
body: `Hi {{name}},
|
||||
|
||||
Every day, scammers use AI-powered tools to clone voices, craft convincing phishing messages, and steal identities. ShieldAI is built to stop them.
|
||||
|
||||
We monitor what matters most:
|
||||
• Dark web scans for your phone number, email, and passwords
|
||||
• AI-powered spam call and text filtering
|
||||
• Voice cloning detection to protect your family
|
||||
• Identity theft monitoring and alerts
|
||||
|
||||
And coming soon — home title protection.
|
||||
|
||||
You're on the ground floor of something important. As a waitlist member, you'll get early access, exclusive updates, and a special launch offer.
|
||||
|
||||
Stay safe,
|
||||
The ShieldAI Team`,
|
||||
htmlBody: buildEmailHtml({
|
||||
previewText: 'Discover how ShieldAI protects you from AI-powered scams.',
|
||||
title: 'Your Privacy Protection Starts Here',
|
||||
bodyContent: waitlistBody(`Hi {{name}},
|
||||
|
||||
Every day, scammers use AI-powered tools to clone voices, craft convincing phishing messages, and steal identities. ShieldAI is built to stop them.
|
||||
|
||||
<strong>What we monitor:</strong>
|
||||
• Dark web scans for your phone number, email, and passwords
|
||||
• AI-powered spam call and text filtering
|
||||
• Voice cloning detection to protect your family
|
||||
• Identity theft monitoring and real-time alerts
|
||||
|
||||
And coming soon — home title protection.
|
||||
|
||||
As a waitlist member, you'll get early access, exclusive updates, and a special launch offer.`),
|
||||
ctaText: 'Learn More About ShieldAI',
|
||||
ctaUrl: 'https://shieldai.com',
|
||||
footerNote: 'Email 2 of 4 in your welcome sequence. You received this because you joined the ShieldAI waitlist.',
|
||||
}),
|
||||
variables: [
|
||||
{ name: 'name', type: 'string', required: false, defaultValue: 'there' },
|
||||
{ name: 'unsubscribe_url', type: 'string', required: false, defaultValue: 'https://shieldai.com/unsubscribe' },
|
||||
],
|
||||
},
|
||||
|
||||
// Email 3: Day 3 — Features Deep Dive
|
||||
{
|
||||
id: 'waitlist_features',
|
||||
name: 'ShieldAI Features Overview',
|
||||
channel: 'email',
|
||||
locale: 'en',
|
||||
category: 'waitlist',
|
||||
subject: 'See What ShieldAI Can Do For You',
|
||||
body: `Hi {{name}},
|
||||
|
||||
Let's dive into what ShieldAI actually does. Here's a closer look at each layer of protection:
|
||||
|
||||
🔍 DarkWatch — Dark Web Monitoring
|
||||
We continuously scan dark web forums, data breaches, and credential dumps for your personal information. If your email, phone, or passwords appear somewhere they shouldn't, you'll know instantly.
|
||||
|
||||
📞 SpamShield — Call & Text Protection
|
||||
AI-powered filtering that blocks spam calls, detects phishing texts, and flags suspicious numbers before they reach you. Works across your phone lines.
|
||||
|
||||
🎙️ VoicePrint — Voice Clone Detection
|
||||
One of the most alarming new scams: AI voice cloning. VoicePrint analyzes incoming calls for synthetic voice patterns and alerts you if someone is impersonating a loved one.
|
||||
|
||||
🏠 Coming Soon: Home Title Monitoring
|
||||
We're building protection against property fraud — alerting you to any changes in your home's title or deed.
|
||||
|
||||
Want to dive deeper? Check out our blog for detailed guides on each feature.`,
|
||||
htmlBody: buildEmailHtml({
|
||||
previewText: 'A closer look at DarkWatch, SpamShield, VoicePrint, and more.',
|
||||
title: 'See What ShieldAI Can Do For You',
|
||||
bodyContent: waitlistBody(`Hi {{name}},
|
||||
|
||||
Let's dive into what ShieldAI actually does:
|
||||
|
||||
<strong>🔍 DarkWatch — Dark Web Monitoring</strong>
|
||||
We continuously scan dark web forums, data breaches, and credential dumps for your personal information. If your email, phone, or passwords appear somewhere they shouldn't, you'll know instantly.
|
||||
|
||||
<strong>📞 SpamShield — Call & Text Protection</strong>
|
||||
AI-powered filtering that blocks spam calls, detects phishing texts, and flags suspicious numbers before they reach you.
|
||||
|
||||
<strong>🎙️ VoicePrint — Voice Clone Detection</strong>
|
||||
One of the most alarming new scams: AI voice cloning. VoicePrint analyzes calls for synthetic voice patterns and alerts you if someone is impersonating a loved one.
|
||||
|
||||
<strong>🏠 Coming Soon: Home Title Monitoring</strong>
|
||||
Protection against property fraud — alerting you to changes in your home's title or deed.`),
|
||||
ctaText: 'Read Our Privacy Guides',
|
||||
ctaUrl: 'https://shieldai.com/blog',
|
||||
footerNote: 'Email 3 of 4 in your welcome sequence. You received this because you joined the ShieldAI waitlist.',
|
||||
}),
|
||||
variables: [
|
||||
{ name: 'name', type: 'string', required: false, defaultValue: 'there' },
|
||||
{ name: 'unsubscribe_url', type: 'string', required: false, defaultValue: 'https://shieldai.com/unsubscribe' },
|
||||
],
|
||||
},
|
||||
|
||||
// Email 4: Day 7 — Launch Teaser
|
||||
{
|
||||
id: 'waitlist_launch_teaser',
|
||||
name: 'ShieldAI Launch Teaser',
|
||||
channel: 'email',
|
||||
locale: 'en',
|
||||
category: 'waitlist',
|
||||
subject: 'Something Big Is Coming — Get Ready',
|
||||
body: `Hi {{name}},
|
||||
|
||||
Big news — we're getting ready to launch.
|
||||
|
||||
As an early waitlist member, here's what you need to know:
|
||||
|
||||
🚀 Launch Timeline
|
||||
We're putting the final touches on ShieldAI and preparing for our public launch. You'll be among the first to get access.
|
||||
|
||||
🎁 Your Early Adopter Perks
|
||||
• Priority access before the general public
|
||||
• Exclusive launch pricing for waitlist members
|
||||
• Free DarkWatch scan setup when you join
|
||||
|
||||
📣 Spread the Word
|
||||
Know someone who could use better privacy protection? Share your waitlist link and move up the list:
|
||||
|
||||
{{referral_url}}
|
||||
|
||||
We'll be in touch soon with more details. Get ready to take control of your digital life.
|
||||
|
||||
Stay safe,
|
||||
The ShieldAI Team`,
|
||||
htmlBody: buildEmailHtml({
|
||||
previewText: 'Launch is near. Here\'s what waitlist members need to know.',
|
||||
title: 'Something Big Is Coming',
|
||||
bodyContent: waitlistBody(`Hi {{name}},
|
||||
|
||||
Big news — we're getting ready to launch.
|
||||
|
||||
<strong>🚀 Launch Timeline</strong>
|
||||
We're putting the final touches on ShieldAI. You'll be among the first to get access.
|
||||
|
||||
<strong>🎁 Your Early Adopter Perks</strong>
|
||||
• Priority access before the general public
|
||||
• Exclusive launch pricing for waitlist members
|
||||
• Free DarkWatch scan setup when you join
|
||||
|
||||
<strong>📣 Spread the Word</strong>
|
||||
Know someone who could use better privacy protection? Share your referral link:
|
||||
|
||||
{{referral_url}}`),
|
||||
ctaText: 'Share ShieldAI',
|
||||
ctaUrl: 'https://shieldai.com',
|
||||
footerNote: 'Email 4 of 4 in your welcome sequence. This is our last pre-launch update — stay tuned for launch day!',
|
||||
}),
|
||||
variables: [
|
||||
{ name: 'name', type: 'string', required: false, defaultValue: 'there' },
|
||||
{ name: 'referral_url', type: 'string', required: false, defaultValue: 'https://shieldai.com/waitlist' },
|
||||
{ name: 'unsubscribe_url', type: 'string', required: false, defaultValue: 'https://shieldai.com/unsubscribe' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const AllDefaultTemplates: TemplateDefinition[] = [
|
||||
...DefaultEmailTemplates,
|
||||
...WaitlistEmailTemplates,
|
||||
...DefaultSMSTemplates,
|
||||
...DefaultPushTemplates,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
export interface EmailLayoutOptions {
|
||||
previewText: string;
|
||||
title: string;
|
||||
bodyContent: string;
|
||||
ctaText?: string;
|
||||
ctaUrl?: string;
|
||||
footerNote?: string;
|
||||
}
|
||||
|
||||
const BRAND_COLORS = {
|
||||
bgPrimary: '#0a0f1e',
|
||||
bgCard: '#1a2332',
|
||||
textPrimary: '#f1f5f9',
|
||||
textSecondary: '#94a3b8',
|
||||
textMuted: '#64748b',
|
||||
accentPrimary: '#3b82f6',
|
||||
accentSecondary: '#06b6d4',
|
||||
borderColor: '#1e293b',
|
||||
};
|
||||
|
||||
export function buildEmailHtml(opts: EmailLayoutOptions): string {
|
||||
const ctaBlock = opts.ctaText && opts.ctaUrl
|
||||
? `<tr>
|
||||
<td align="center" style="padding: 32px 0 0;">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0">
|
||||
<tr>
|
||||
<td style="border-radius: 8px; background: linear-gradient(135deg, ${BRAND_COLORS.accentPrimary}, ${BRAND_COLORS.accentSecondary}); padding: 14px 36px;">
|
||||
<a href="${opts.ctaUrl}" style="color: #ffffff; font-size: 16px; font-weight: 600; font-family: 'Inter', Arial, Helvetica, sans-serif; text-decoration: none; display: inline-block;">${opts.ctaText}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>`
|
||||
: '';
|
||||
|
||||
const noteBlock = opts.footerNote
|
||||
? `<tr><td style="padding: 24px 0 0; color: ${BRAND_COLORS.textMuted}; font-size: 14px; line-height: 1.6;">${opts.footerNote}</td></tr>`
|
||||
: '';
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="supported-color-schemes" content="dark">
|
||||
<title>${opts.title}</title>
|
||||
<style>
|
||||
@media only screen and (max-width: 600px) {
|
||||
.email-container { width: 100% !important; }
|
||||
.email-padding { padding: 0 16px !important; }
|
||||
.card-padding { padding: 32px 24px !important; }
|
||||
.cta-button { padding: 14px 28px !important; font-size: 15px !important; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background-color: ${BRAND_COLORS.bgPrimary}; font-family: 'Inter', Arial, Helvetica, sans-serif; -webkit-font-smoothing: antialiased;">
|
||||
<div style="display: none; max-height: 0; overflow: hidden; color: ${BRAND_COLORS.bgPrimary}; font-size: 1px; line-height: 1px;">${opts.previewText}</div>
|
||||
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: ${BRAND_COLORS.bgPrimary};">
|
||||
<tr>
|
||||
<td align="center" style="padding: 40px 0;">
|
||||
<table class="email-container" role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" style="max-width: 600px; width: 100%;">
|
||||
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td class="email-padding" align="center" style="padding: 0 24px 32px;">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0">
|
||||
<tr>
|
||||
<td style="font-size: 28px; font-weight: 800; background: linear-gradient(135deg, ${BRAND_COLORS.accentPrimary}, ${BRAND_COLORS.accentSecondary}); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; letter-spacing: -0.02em;">
|
||||
ShieldAI
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Main Card -->
|
||||
<tr>
|
||||
<td class="email-padding" style="padding: 0 24px;">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: ${BRAND_COLORS.bgCard}; border-radius: 12px; border: 1px solid ${BRAND_COLORS.borderColor};">
|
||||
<tr>
|
||||
<td class="card-padding" style="padding: 48px 40px;">
|
||||
|
||||
<!-- Title -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="color: ${BRAND_COLORS.textPrimary}; font-size: 24px; font-weight: 700; line-height: 1.3; padding-bottom: 16px;">
|
||||
${opts.title}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Body -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="color: ${BRAND_COLORS.textSecondary}; font-size: 16px; line-height: 1.7;">
|
||||
${opts.bodyContent}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
${ctaBlock}
|
||||
${noteBlock}
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td align="center" style="padding: 32px 24px 0;">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="color: ${BRAND_COLORS.textMuted}; font-size: 13px; line-height: 1.5; text-align: center;">
|
||||
<p style="margin: 0 0 8px;">ShieldAI — Protecting what matters most</p>
|
||||
<p style="margin: 0 0 8px;">
|
||||
<a href="{{unsubscribe_url}}" style="color: ${BRAND_COLORS.textMuted}; text-decoration: underline;">Unsubscribe</a>
|
||||
·
|
||||
<a href="https://shieldai.com" style="color: ${BRAND_COLORS.textMuted}; text-decoration: underline;">Visit Website</a>
|
||||
</p>
|
||||
<p style="margin: 0;">© 2026 ShieldAI. All rights reserved.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, createSignal } from 'solid-js';
|
||||
import { Component, createSignal, onMount } from 'solid-js';
|
||||
import { trackWaitlistSignup } from '../hooks/useAnalytics';
|
||||
|
||||
interface WaitlistFormProps {
|
||||
@@ -7,14 +7,29 @@ interface WaitlistFormProps {
|
||||
buttonText?: string;
|
||||
}
|
||||
|
||||
function getUtmParams() {
|
||||
if (typeof window === 'undefined') return {};
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return {
|
||||
utmSource: params.get('utm_source') || undefined,
|
||||
utmMedium: params.get('utm_medium') || undefined,
|
||||
utmCampaign: params.get('utm_campaign') || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const WaitlistForm: Component<WaitlistFormProps> = (props) => {
|
||||
const [email, setEmail] = createSignal('');
|
||||
const [name, setName] = createSignal('');
|
||||
const [tier, setTier] = createSignal('basic');
|
||||
const [utm, setUtm] = createSignal<Record<string, string | undefined>>({});
|
||||
const [submitted, setSubmitted] = createSignal(false);
|
||||
const [loading, setLoading] = createSignal(false);
|
||||
const [error, setError] = createSignal('');
|
||||
|
||||
onMount(() => {
|
||||
setUtm(getUtmParams());
|
||||
});
|
||||
|
||||
const variant = props.variant || 'hero';
|
||||
|
||||
const handleSubmit = async (e: Event) => {
|
||||
@@ -36,6 +51,7 @@ const WaitlistForm: Component<WaitlistFormProps> = (props) => {
|
||||
email: email(),
|
||||
name: name() || undefined,
|
||||
tier: tier() !== 'basic' ? tier() : undefined,
|
||||
...utm(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -2,12 +2,17 @@ type EventParams = Record<string, string | number | boolean | undefined>;
|
||||
|
||||
const GA_MEASUREMENT_ID = import.meta.env.VITE_GA_MEASUREMENT_ID as string | undefined;
|
||||
const MIXPANEL_TOKEN = import.meta.env.VITE_MIXPANEL_TOKEN as string | undefined;
|
||||
const META_PIXEL_ID = import.meta.env.VITE_META_PIXEL_ID as string | undefined;
|
||||
const LINKEDIN_PARTNER_ID = import.meta.env.VITE_LINKEDIN_PARTNER_ID as string | undefined;
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
gtag?: (command: string, target: string, params?: EventParams) => void;
|
||||
mixpanel?: { track: (event: string, params?: EventParams) => void };
|
||||
mixpanel?: { track: (event: string, params?: EventParams) => void; init?: (token: string) => void };
|
||||
dataLayer?: unknown[];
|
||||
fbq?: (...args: unknown[]) => void;
|
||||
_fbq?: unknown;
|
||||
lintrk?: (...args: unknown[]) => void;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,9 +48,47 @@ function initMixpanel() {
|
||||
};
|
||||
}
|
||||
|
||||
function initMetaPixel() {
|
||||
if (!META_PIXEL_ID || typeof window === 'undefined') return;
|
||||
if (window.fbq) return;
|
||||
|
||||
window.fbq = function fbq() { window._fbq = window._fbq || []; window._fbq.push(arguments); };
|
||||
const script = document.createElement('script');
|
||||
script.async = true;
|
||||
script.src = `https://connect.facebook.net/en_US/fbevents.js`;
|
||||
document.head.appendChild(script);
|
||||
|
||||
window.fbq('init', META_PIXEL_ID);
|
||||
window.fbq('track', 'PageView');
|
||||
}
|
||||
|
||||
function initLinkedInInsight() {
|
||||
if (!LINKEDIN_PARTNER_ID || typeof window === 'undefined') return;
|
||||
if (window.lintrk) return;
|
||||
|
||||
window.lintrk = function lintrk() { window.lintrk.q.push(arguments); };
|
||||
window.lintrk.q = [];
|
||||
const script = document.createElement('script');
|
||||
script.async = true;
|
||||
script.src = `https://snap.licdn.com/li.lms-analytics/insight.min.js`;
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
export function initAnalytics() {
|
||||
initGA();
|
||||
initMixpanel();
|
||||
initMetaPixel();
|
||||
initLinkedInInsight();
|
||||
}
|
||||
|
||||
export function trackMetaEvent(eventName: string, params?: EventParams) {
|
||||
if (typeof window === 'undefined' || !window.fbq) return;
|
||||
window.fbq('track', eventName, params);
|
||||
}
|
||||
|
||||
export function trackLinkedInEvent() {
|
||||
if (typeof window === 'undefined' || !window.lintrk) return;
|
||||
window.lintrk('track', { conversion_id: null });
|
||||
}
|
||||
|
||||
export function trackEvent(name: string, params?: EventParams) {
|
||||
@@ -60,12 +103,23 @@ export function trackEvent(name: string, params?: EventParams) {
|
||||
}
|
||||
}
|
||||
|
||||
function hashEmail(email: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < email.length; i++) {
|
||||
const char = email.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash;
|
||||
}
|
||||
return Math.abs(hash).toString(16);
|
||||
}
|
||||
|
||||
export function trackWaitlistSignup(email: string, source?: string, tier?: string) {
|
||||
trackEvent('waitlist_signup', {
|
||||
email,
|
||||
source: source || 'landing_page',
|
||||
tier: tier || 'unknown',
|
||||
});
|
||||
trackMetaEvent('Lead', { value: 5.00, currency: 'USD', eventID: hashEmail(email) });
|
||||
}
|
||||
|
||||
export function trackPageView(path: string, title?: string) {
|
||||
|
||||
@@ -855,6 +855,31 @@ img {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Blog Waitlist CTA */
|
||||
.blog-waitlist-cta {
|
||||
background: var(--bg-card);
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding: 80px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.blog-waitlist-cta h2 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.blog-waitlist-cta p {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 32px;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.blog-waitlist-cta .hero-form {
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1024px) {
|
||||
.features-grid {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { render } from 'solid-js/web';
|
||||
import { Router, Route } from '@solidjs/router';
|
||||
import App from './App';
|
||||
import LandingPage from './pages/LandingPage';
|
||||
import AdsLandingPage from './pages/AdsLandingPage';
|
||||
import BlogPage from './pages/BlogPage';
|
||||
import BlogPostPage from './pages/BlogPostPage';
|
||||
import './index.css';
|
||||
@@ -12,6 +13,7 @@ if (!root) throw new Error('Root element not found');
|
||||
render(() => (
|
||||
<Router root={App}>
|
||||
<Route path="/" component={LandingPage} />
|
||||
<Route path="/ads" component={AdsLandingPage} />
|
||||
<Route path="/blog" component={BlogPage} />
|
||||
<Route path="/blog/:slug" component={BlogPostPage} />
|
||||
</Router>
|
||||
|
||||
24
packages/web/src/pages/AdsLandingPage.tsx
Normal file
24
packages/web/src/pages/AdsLandingPage.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Component, onMount } from 'solid-js';
|
||||
import { initAnalytics, trackPageView } from '../hooks/useAnalytics';
|
||||
import HeroSection from '../components/HeroSection';
|
||||
import FeaturesSection from '../components/FeaturesSection';
|
||||
import TierComparison from '../components/TierComparison';
|
||||
import Footer from '../components/Footer';
|
||||
|
||||
const AdsLandingPage: Component = () => {
|
||||
onMount(() => {
|
||||
initAnalytics();
|
||||
trackPageView('/ads', 'ShieldAI — Ads | AI-Powered Identity Protection');
|
||||
});
|
||||
|
||||
return (
|
||||
<main>
|
||||
<HeroSection />
|
||||
<FeaturesSection />
|
||||
<TierComparison />
|
||||
<Footer />
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdsLandingPage;
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Component, createSignal, onMount, For } from 'solid-js';
|
||||
import { initAnalytics, trackPageView } from '../hooks/useAnalytics';
|
||||
import WaitlistForm from '../components/WaitlistForm';
|
||||
import Footer from '../components/Footer';
|
||||
|
||||
interface BlogPost {
|
||||
@@ -104,6 +105,14 @@ const BlogPage: Component = () => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="blog-waitlist-cta">
|
||||
<div class="container">
|
||||
<h2>Stay Protected</h2>
|
||||
<p>Get notified when we publish new guides and early access to ShieldAI.</p>
|
||||
<WaitlistForm variant="hero" buttonText="Get Notified" placeholder="your@email.com" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</main>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user