assets, move memories to proper location

This commit is contained in:
2026-05-14 07:36:23 -04:00
parent 0bec3c574a
commit 1b917321cf
52 changed files with 3352 additions and 297 deletions

View File

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

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

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -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 &mdash; 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>
&nbsp;&middot;&nbsp;
<a href="https://shieldai.com" style="color: ${BRAND_COLORS.textMuted}; text-decoration: underline;">Visit Website</a>
</p>
<p style="margin: 0;">&copy; 2026 ShieldAI. All rights reserved.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}

View File

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

View File

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

View File

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

View File

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

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

View File

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