more package declarations

This commit is contained in:
2026-05-17 21:52:38 -04:00
parent a8a5930ced
commit f118d3a4f3
44 changed files with 14019 additions and 1918 deletions

View File

@@ -1,4 +1,11 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import jwt from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET || process.env.NEXTAUTH_SECRET;
if (!JWT_SECRET && process.env.NODE_ENV === 'production') {
console.error('JWT_SECRET or NEXTAUTH_SECRET must be set in production');
}
export interface AuthRequest extends FastifyRequest {
user?: {
@@ -27,17 +34,35 @@ export async function authMiddleware(fastify: FastifyInstance) {
if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.slice(7);
try {
// In production, decode and verify JWT
// For now, we'll attach a placeholder user
if (!JWT_SECRET) {
throw new Error('JWT_SECRET not configured');
}
const decoded = jwt.verify(token, JWT_SECRET) as {
id: string;
email: string;
role: string;
organizationId?: string;
iat?: number;
exp?: number;
};
authReq.user = {
id: 'user-placeholder',
email: 'user@example.com',
role: 'user',
id: decoded.id,
email: decoded.email,
role: decoded.role,
organizationId: decoded.organizationId,
};
authReq.authType = 'jwt';
return;
} catch (err) {
// JWT invalid, continue to API key check
if (err instanceof jwt.JsonWebTokenError) {
throw { statusCode: 401, message: 'Invalid token' };
}
if (err instanceof jwt.TokenExpiredError) {
throw { statusCode: 401, message: 'Token expired', expiredAt: err.expiredAt };
}
throw { statusCode: 401, message: 'Authentication failed' };
}
}

View File

@@ -7,39 +7,97 @@ function getUserId(request: FastifyRequest): string | undefined {
return (request.user as AuthUser | undefined)?.id;
}
const timeWindowSchema = {
type: "object",
properties: {
timeWindow: { type: "integer", minimum: 1, maximum: 10080 },
},
};
const paginatedQuerySchema = {
type: "object",
properties: {
timeWindow: { type: "integer", minimum: 1, maximum: 10080 },
limit: { type: "integer", minimum: 1, maximum: 200 },
offset: { type: "integer", minimum: 0, maximum: 10000 },
},
};
export function correlationRoutes(fastify: FastifyInstance) {
fastify.get("/dashboard", async (request, reply) => {
const userId = getUserId(request);
if (!userId || userId === "anonymous") {
return reply.code(401).send({ error: "User not authenticated" });
fastify.get(
"/dashboard",
{
schema: {
...timeWindowSchema,
response: {
"400": {
type: "object",
properties: {
error: { type: "string" },
},
},
"401": {
type: "object",
properties: {
error: { type: "string" },
},
},
},
},
},
async (request, reply) => {
const userId = getUserId(request);
if (!userId || userId === "anonymous") {
return reply.code(401).send({ error: "User not authenticated" });
}
const query = request.query as Record<string, string | number>;
const timeWindow =
typeof query.timeWindow === "number" ? query.timeWindow : 60;
const data = await correlationService.getDashboardData(userId, timeWindow);
return reply.send(data);
}
);
const timeWindow =
parseInt(
(request.query as Record<string, string>).timeWindow as string
) || 60;
const data = await correlationService.getDashboardData(userId, timeWindow);
return reply.send(data);
});
fastify.get(
"/groups",
{
schema: {
...paginatedQuerySchema,
response: {
"400": {
type: "object",
properties: {
error: { type: "string" },
},
},
"401": {
type: "object",
properties: {
error: { type: "string" },
},
},
},
},
},
async (request, reply) => {
const userId = getUserId(request);
if (!userId || userId === "anonymous") {
return reply.code(401).send({ error: "User not authenticated" });
}
fastify.get("/groups", async (request, reply) => {
const userId = getUserId(request);
if (!userId || userId === "anonymous") {
return reply.code(401).send({ error: "User not authenticated" });
const query = request.query as Record<string, string | number>;
const result = await correlationService.getCorrelationGroups({
userId,
status: (query.status as any) || undefined,
timeWindowMinutes:
typeof query.timeWindow === "number" ? query.timeWindow : 60,
limit: typeof query.limit === "number" ? query.limit : 50,
offset: typeof query.offset === "number" ? query.offset : 0,
});
return reply.send(result);
}
const query = request.query as Record<string, string>;
const result = await correlationService.getCorrelationGroups({
userId,
status: (query.status as any) || undefined,
timeWindowMinutes: query.timeWindow
? parseInt(query.timeWindow)
: 60,
limit: query.limit ? parseInt(query.limit) : 50,
offset: query.offset ? parseInt(query.offset) : 0,
});
return reply.send(result);
});
);
fastify.get(
"/groups/:groupId",
@@ -114,26 +172,47 @@ export function correlationRoutes(fastify: FastifyInstance) {
}
);
fastify.get("/alerts", async (request, reply) => {
const userId = getUserId(request);
if (!userId || userId === "anonymous") {
return reply.code(401).send({ error: "User not authenticated" });
}
fastify.get(
"/alerts",
{
schema: {
...paginatedQuerySchema,
response: {
"400": {
type: "object",
properties: {
error: { type: "string" },
},
},
"401": {
type: "object",
properties: {
error: { type: "string" },
},
},
},
},
},
async (request, reply) => {
const userId = getUserId(request);
if (!userId || userId === "anonymous") {
return reply.code(401).send({ error: "User not authenticated" });
}
const query = request.query as Record<string, string>;
const result = await correlationService.getCorrelatedAlerts({
userId,
source: (query.source as any) || undefined,
category: (query.category as any) || undefined,
severity: (query.severity as any) || undefined,
timeWindowMinutes: query.timeWindow
? parseInt(query.timeWindow)
: 60,
limit: query.limit ? parseInt(query.limit) : 50,
offset: query.offset ? parseInt(query.offset) : 0,
});
return reply.send(result);
});
const query = request.query as Record<string, string | number>;
const result = await correlationService.getCorrelatedAlerts({
userId,
source: (query.source as any) || undefined,
category: (query.category as any) || undefined,
severity: (query.severity as any) || undefined,
timeWindowMinutes:
typeof query.timeWindow === "number" ? query.timeWindow : 60,
limit: typeof query.limit === "number" ? query.limit : 50,
offset: typeof query.offset === "number" ? query.offset : 0,
});
return reply.send(result);
}
);
fastify.post(
"/ingest/darkwatch",

View File

@@ -169,4 +169,9 @@ export async function reportRoutes(fastify: FastifyInstance) {
const createdIds = await reportService.scheduleAnnualReports();
return reply.code(200).send({ scheduled: createdIds.length, reportIds: createdIds });
});
fastify.post('/schedule/weekly-digest', async (request: FastifyRequest, reply: FastifyReply) => {
const createdIds = await reportService.scheduleWeeklyDigest();
return reply.code(200).send({ scheduled: createdIds.length, reportIds: createdIds });
});
}

View File

@@ -4,6 +4,7 @@ import Fastify from "fastify";
import cors from "@fastify/cors";
import helmet from "@fastify/helmet";
import sensible from "@fastify/sensible";
import rawBody from "fastify-raw-body";
import { extractOrGenerateRequestId } from "@shieldai/types";
import { authMiddleware } from "./middleware/auth.middleware";
import { errorHandlingMiddleware } from "./middleware/error-handling.middleware";
@@ -16,8 +17,13 @@ import { extensionRoutes } from "./routes/extension.routes";
import { waitlistRoutes } from "./routes/waitlist.routes";
import { blogRoutes } from "./routes/blog.routes";
import { blogAdminRoutes } from "./routes/blog-admin.routes";
import { routes } from "./routes";
import { captureSentryError } from "@shieldai/monitoring";
import { getCorsOrigins } from "./config/api.config";
import fastifySwagger from "@fastify/swagger";
import fastifySwaggerUi from "@fastify/swagger-ui";
import * as fs from "fs";
import * as path from "path";
const app = Fastify({
logger: {
@@ -30,6 +36,7 @@ async function bootstrap() {
await app.register(cors, { origin: corsOrigins });
await app.register(helmet);
await app.register(sensible);
await app.register(rawBody, { runFirst: true });
// Register auth middleware to populate request.user
await app.register(authMiddleware);
@@ -52,16 +59,54 @@ async function bootstrap() {
request.headers["x-request-id"] = requestId;
});
await app.register(darkwatchRoutes);
await app.register(voiceprintRoutes);
await app.register(correlationRoutes);
await app.register(extensionRoutes, { prefix: '/extension' });
await app.register(waitlistRoutes);
await app.register(blogRoutes, { prefix: '/blog' });
await app.register(blogAdminRoutes);
await app.register(routes);
app.get("/health", async () => ({ status: "ok", timestamp: new Date().toISOString() }));
// Swagger/OpenAPI documentation
const openapiSpec = JSON.parse(
fs.readFileSync(path.join(__dirname, "openapi", "spec.json"), "utf-8"),
) as Record<string, unknown>;
const swaggerDefinition: Record<string, unknown> = {
openapi: "3.0.3",
info: {
title: "ShieldAI API",
description:
"ShieldAI API documentation — reverse-engineer endpoints and run contract tests",
version: "1.0.0",
},
servers: openapiSpec.servers,
paths: openapiSpec.paths,
components: openapiSpec.components,
security: openapiSpec.security,
tags: openapiSpec.tags,
};
await app.register(fastifySwagger, {
openapi: swaggerDefinition,
});
await app.register(fastifySwaggerUi, {
routePrefix: "/docs",
uiConfig: {
docExpansion: "list",
},
staticCSP: true,
theme: {
js: [
{
filename: "custom.js",
content: `
window.addEventListener('DOMContentLoaded', () => {
document.querySelector('link[rel="icon"]')?.remove();
});
`,
},
],
},
});
try {
await app.listen({ port: parseInt(process.env.PORT || "3000", 10), host: "0.0.0.0" });
app.log.info(`Server listening on port ${process.env.PORT || 3000}`);

View File

@@ -0,0 +1,36 @@
-- Create device types enum
DO $$ BEGIN
CREATE TYPE "DeviceType" AS ENUM('mobile', 'web', 'desktop');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Create platform enum
DO $$ BEGIN
CREATE TYPE "Platform" AS ENUM('ios', 'android', 'web');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Create device_tokens table
CREATE TABLE IF NOT EXISTS "device_tokens" (
"id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"userId" UUID NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
"deviceType" "DeviceType" NOT NULL DEFAULT 'mobile',
"token" TEXT NOT NULL UNIQUE,
"platform" "Platform" NOT NULL,
"appName" TEXT,
"appVersion" TEXT,
"osVersion" TEXT,
"model" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"lastUsedAt" TIMESTAMP NOT NULL DEFAULT NOW(),
"createdAt" TIMESTAMP NOT NULL DEFAULT NOW(),
"updatedAt" TIMESTAMP NOT NULL DEFAULT NOW()
);
-- Create indexes
CREATE INDEX IF NOT EXISTS "device_tokens_userId_idx" ON "device_tokens"("userId");
CREATE INDEX IF NOT EXISTS "device_tokens_deviceType_idx" ON "device_tokens"("deviceType");
CREATE INDEX IF NOT EXISTS "device_tokens_platform_idx" ON "device_tokens"("platform");
CREATE INDEX IF NOT EXISTS "device_tokens_isActive_idx" ON "device_tokens"("isActive");

View File

@@ -2,7 +2,9 @@
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
"rootDir": "./src",
"module": "ES2022",
"moduleResolution": "Bundler"
},
"include": ["src/**/*.ts"]
}

View File

@@ -75,16 +75,42 @@ async function processReportGeneration(
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`,
},
});
const templateId = report.reportType === 'WEEKLY_DIGEST' ? 'weekly_digest' : 'report_ready';
if (report.reportType === 'WEEKLY_DIGEST' && report.dataPayload) {
const payload = typeof report.dataPayload === 'string' ? JSON.parse(report.dataPayload) : report.dataPayload;
await emailService.sendWithTemplate(notifyEmail, {
templateId: 'weekly_digest',
variables: {
name: userName,
period_start: new Date(report.periodStart).toLocaleDateString('en-US', { month: 'long', day: 'numeric' }),
period_end: new Date(report.periodEnd).toLocaleDateString('en-US', { month: 'long', day: 'numeric' }),
protection_score: payload.protectionScore || 0,
new_exposures: payload.exposureSummary?.newExposures || 0,
critical_exposures: payload.exposureSummary?.criticalExposures || 0,
spam_events_blocked: payload.spamStats?.totalSpamEvents || 0,
calls_blocked: payload.spamStats?.callsBlocked || 0,
texts_blocked: payload.spamStats?.textsBlocked || 0,
voice_threats: payload.voiceStats?.threatsDetected || 0,
enrollments_active: payload.voiceStats?.enrollmentsActive || 0,
properties_monitored: payload.homeTitleStats?.propertiesMonitored || 0,
changes_detected: payload.homeTitleStats?.changesDetected || 0,
report_url: `${dashboardUrl}/reports/${report.id}`,
},
});
} else {
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({
where: { id: report.id },
@@ -245,6 +271,13 @@ export async function scheduleMonthlyReportTrigger() {
});
}
export async function scheduleWeeklyDigestTrigger() {
return reportSchedulerQueue.add('trigger-weekly-digest', {}, {
repeat: { pattern: '0 8 * * 1' },
jobId: 'weekly-digest-trigger',
});
}
export async function scheduleAnnualReportTrigger() {
return reportSchedulerQueue.add('trigger-annual-reports', {}, {
repeat: { pattern: '0 0 1 1 *' },

View File

@@ -0,0 +1,237 @@
# @shieldai/mobile-api-client
React Native API client library for ShieldAI services. Provides type-safe access to all API endpoints with built-in authentication, offline support, and error handling.
## Installation
```bash
npm install @shieldai/mobile-api-client
# or
yarn add @shieldai/mobile-api-client
```
## Setup
### Initialize the client
```typescript
import { createApiClient } from '@shieldai/mobile-api-client';
createApiClient({
baseURL: 'https://api.shieldai.freno.me/api/v1',
timeout: 30000,
debug: __DEV__, // Enable debug logging in development
});
```
### Authentication
```typescript
import { authService } from '@shieldai/mobile-api-client';
// Login
const { user, tokens } = await authService.login({
email: 'user@example.com',
password: 'password123',
});
// Register
const { user: newUser } = await authService.register({
email: 'user@example.com',
password: 'password123',
firstName: 'John',
lastName: 'Doe',
});
// Get current user
const currentUser = await authService.getCurrentUser();
// Logout
await authService.logout();
// Check authentication status
const isAuthenticated = await authService.isAuthenticated();
```
### Device Management
```typescript
import { deviceService } from '@shieldai/mobile-api-client';
import * as Notifications from 'expo-notifications';
// Register device for push notifications
async function registerForPushNotifications() {
const token = (await Notifications.getExpoPushTokenAsync({
projectId: 'your-project-id',
})).data;
await deviceService.registerDevice({
platform: Platform.OS === 'ios' ? 'ios' : 'android',
pushToken: token,
modelName: Platform.OS === 'ios' ? 'iPhone' : 'Android',
osVersion: Platform.Version.toString(),
appVersion: '1.0.0',
});
}
// Get all user devices
const { devices } = await deviceService.getDevices();
// Update push token
await deviceService.updatePushToken('new-token');
```
### Subscriptions
```typescript
import { subscriptionService } from '@shieldai/mobile-api-client';
// Get current subscription
const { subscription, tier, usage } = await subscriptionService.getSubscription();
// Get available tiers
const tiers = await subscriptionService.getTiers();
// Create subscription
const newSubscription = await subscriptionService.createSubscription({
tier: 'premium',
});
// Update subscription
await subscriptionService.updateSubscription({
tier: 'enterprise',
});
// Cancel subscription
await subscriptionService.cancelSubscription();
// Create checkout session
const { url } = await subscriptionService.createCheckoutSession('premium');
Linking.openURL(url);
// Create customer portal session
const { url: portalUrl } = await subscriptionService.createCustomerPortalSession();
Linking.openURL(portalUrl);
```
### Notifications
```typescript
import { notificationService } from '@shieldai/mobile-api-client';
// Get notifications
const { notifications, unreadCount } = await notificationService.getNotifications({
page: 1,
limit: 20,
unreadOnly: false,
});
// Mark as read
await notificationService.markAsRead(notificationId);
// Mark all as read
await notificationService.markAllAsRead();
// Get unread count
const count = await notificationService.getUnreadCount();
// Update preferences
await notificationService.updatePreferences({
emailNotifications: true,
pushNotifications: true,
notificationTypes: {
darkwatch_alert: true,
spam_blocked: true,
voiceprint_analysis: true,
},
});
```
## Features
### Automatic Token Refresh
The client automatically handles JWT token refresh when access tokens expire:
```typescript
// No manual handling needed - just make the request
const user = await authService.getCurrentUser();
// If token expired, it will be refreshed automatically
```
### Offline Support
Requests are automatically queued when offline and replayed when connection is restored:
```typescript
import { requestQueue } from '@shieldai/mobile-api-client';
// Subscribe to queue status changes
const unsubscribe = requestQueue.subscribe(() => {
const status = requestQueue.getStatus();
console.log(`Queued requests: ${status.size}`);
});
// Cleanup
unsubscribe();
```
### Error Handling
```typescript
import { authService } from '@shieldai/mobile-api-client';
try {
await authService.login({ email, password });
} catch (error) {
if (error.response?.status === 401) {
// Invalid credentials
} else if (error.response?.status === 422) {
// Validation error
} else if (error.offline) {
// Offline mode - request queued
} else {
// Network error
}
}
```
## API Reference
### Services
- `authService` - Authentication and user management
- `deviceService` - Device registration and push tokens
- `subscriptionService` - Billing and subscription management
- `notificationService` - Push notifications and preferences
### Types
All TypeScript types are exported for type-safe development:
```typescript
import type { User, Device, Subscription, Notification } from '@shieldai/mobile-api-client';
```
## Development
```bash
# Install dependencies
npm install
# Build
npm run build
# Watch mode
npm run dev
# Type check
npx tsc --noEmit
# Lint
npm run lint
```
## License
MIT

View File

@@ -0,0 +1,29 @@
{
"name": "@shieldai/mobile-api-client",
"version": "1.0.0",
"description": "React Native API client library for ShieldAI services",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"lint": "eslint src/",
"test": "jest"
},
"keywords": ["react-native", "api-client", "shieldai"],
"author": "ShieldAI Team",
"license": "MIT",
"peerDependencies": {
"react-native": ">=0.72.0",
"expo": ">=49.0.0"
},
"dependencies": {
"expo-secure-store": "^12.8.0",
"@react-native-async-storage/async-storage": "1.23.1",
"axios": "^1.6.0"
},
"devDependencies": {
"@types/react": "^18.2.0",
"typescript": "^5.3.0"
}
}

View File

@@ -0,0 +1,249 @@
/**
* API Client for ShieldAI services
* Handles authentication, request/response interception, and error handling
*/
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios';
import { tokenStorage } from '../storage/token-storage';
import { requestQueue } from '../utils/request-queue';
import type { AuthTokens, AuthResponse, RefreshTokenRequest } from '../types';
export interface ApiClientConfig {
baseURL: string;
timeout?: number;
debug?: boolean;
}
export class ApiClient {
private client: AxiosInstance;
private config: ApiClientConfig;
private isRefreshing = false;
private refreshSubscribers: Set<(token: string) => void> = new Set();
constructor(config: ApiClientConfig) {
this.config = {
baseURL: config.baseURL,
timeout: config.timeout ?? 30000,
debug: config.debug ?? false,
};
this.client = axios.create({
baseURL: this.config.baseURL,
timeout: this.config.timeout,
headers: {
'Content-Type': 'application/json',
},
});
this.setupInterceptors();
}
private setupInterceptors(): void {
// Request interceptor - add auth token
this.client.interceptors.request.use(
async (config) => {
if (this.config.debug) {
console.log('[API] Request:', config.method?.toUpperCase(), config.url);
}
// Add auth token if available
const token = await tokenStorage.getAccessToken();
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
// Queue request if offline
if (!requestQueue.isOnline() && this.requiresNetwork(config)) {
await requestQueue.enqueue(config);
throw new Error('OFFLINE');
}
return config;
},
(error) => {
if (error.message === 'OFFLINE') {
return Promise.reject({ offline: true, config: error.config });
}
return Promise.reject(error);
}
);
// Response interceptor - handle errors and token refresh
this.client.interceptors.response.use(
(response) => {
if (this.config.debug) {
console.log('[API] Response:', response.status, response.config.url);
}
return response;
},
async (error: AxiosError) => {
const originalRequest = error.config as AxiosRequestConfig & { _retry?: boolean };
// Handle 401 - unauthorized
if (error.response?.status === 401 && !originalRequest._retry) {
if (this.isRefreshing) {
// Wait for refresh to complete
return new Promise((resolve) => {
this.refreshSubscribers.add((token) => {
originalRequest.headers = originalRequest.headers || {};
originalRequest.headers.Authorization = `Bearer ${token}`;
resolve(this.client(originalRequest));
});
});
}
originalRequest._retry = true;
this.isRefreshing = true;
try {
const refreshToken = await tokenStorage.getRefreshToken();
if (!refreshToken) {
throw new Error('No refresh token');
}
const newTokens = await this.refreshAccessToken(refreshToken);
await tokenStorage.saveTokens(newTokens.accessToken, newTokens.refreshToken);
// Retry failed requests
this.refreshSubscribers.forEach((callback) => callback(newTokens.accessToken));
this.refreshSubscribers.clear();
originalRequest.headers = originalRequest.headers || {};
originalRequest.headers.Authorization = `Bearer ${newTokens.accessToken}`;
return this.client(originalRequest);
} catch (refreshError) {
// Refresh failed - clear tokens and redirect to login
await tokenStorage.clearTokens();
this.refreshSubscribers.clear();
return Promise.reject(refreshError);
} finally {
this.isRefreshing = false;
}
}
return Promise.reject(error);
}
);
}
private requiresNetwork(config: AxiosRequestConfig): boolean {
// Don't queue GET requests that can be cached
const method = (config.method || 'get').toLowerCase();
return method !== 'get';
}
private async refreshAccessToken(refreshToken: string): Promise<AuthTokens> {
const response = await this.client.post<AuthResponse>('/auth/refresh', {
refreshToken,
});
return {
accessToken: response.data.tokens.accessToken,
refreshToken: response.data.tokens.refreshToken,
expiresIn: response.data.tokens.expiresIn,
tokenType: response.data.tokens.tokenType,
};
}
// Subscribe to token refresh
onTokenRefresh(callback: (token: string) => void): () => void {
this.refreshSubscribers.add(callback);
return () => this.refreshSubscribers.delete(callback);
}
// Public API methods
async get<T>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.client.get<T>(url, config);
}
async post<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.client.post<T>(url, data, config);
}
async put<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.client.put<T>(url, data, config);
}
async patch<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.client.patch<T>(url, data, config);
}
async delete<T>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.client.delete<T>(url, config);
}
// Auth methods
async login(email: string, password: string): Promise<AuthResponse> {
const response = await this.post<AuthResponse>('/auth/login', {
email,
password,
});
if (response.data.tokens) {
await tokenStorage.saveTokens(
response.data.tokens.accessToken,
response.data.tokens.refreshToken
);
}
return response.data;
}
async register(data: {
email: string;
password: string;
firstName?: string;
lastName?: string;
}): Promise<AuthResponse> {
const response = await this.post<AuthResponse>('/auth/register', data);
if (response.data.tokens) {
await tokenStorage.saveTokens(
response.data.tokens.accessToken,
response.data.tokens.refreshToken
);
}
return response.data;
}
async logout(): Promise<void> {
try {
await this.post('/auth/logout');
} finally {
await tokenStorage.clearTokens();
}
}
async isAuthenticated(): Promise<boolean> {
const token = await tokenStorage.getAccessToken();
return !!token;
}
// Health check
async healthCheck(): Promise<{ status: string; version: string }> {
const response = await this.get<{ status: string; version: string }>('/health');
return response.data;
}
// Get the underlying axios instance for advanced usage
getClient(): AxiosInstance {
return this.client;
}
}
// Default client instance
let defaultClient: ApiClient | null = null;
export const createApiClient = (config: ApiClientConfig): ApiClient => {
defaultClient = new ApiClient(config);
return defaultClient;
};
export const getApiClient = (): ApiClient => {
if (!defaultClient) {
throw new Error('API Client not initialized. Call createApiClient first.');
}
return defaultClient;
};

View File

@@ -0,0 +1,46 @@
/**
* Authentication API service
*/
import { getApiClient } from './api-client';
import type {
User,
AuthResponse,
LoginCredentials,
RegisterData,
AuthTokens
} from '../types';
export class AuthService {
private api = getApiClient();
async login(credentials: LoginCredentials): Promise<AuthResponse> {
const response = await this.api.login(credentials.email, credentials.password);
return response;
}
async register(data: RegisterData): Promise<AuthResponse> {
const response = await this.api.register(data);
return response;
}
async logout(): Promise<void> {
await this.api.logout();
}
async getCurrentUser(): Promise<User> {
const response = await this.api.get<{ user: User; authType: string }>('/auth/user/me');
return response.data.user;
}
async refreshToken(): Promise<AuthTokens> {
const response = await this.api.get<AuthTokens>('/auth/refresh-token');
return response.data;
}
async isAuthenticated(): Promise<boolean> {
return await this.api.isAuthenticated();
}
}
export const authService = new AuthService();

View File

@@ -0,0 +1,47 @@
/**
* Device API service
*/
import { getApiClient } from './api-client';
import type { Device, DeviceRegistration, DeviceListResponse } from '../types';
export class DeviceService {
private api = getApiClient();
async registerDevice(data: DeviceRegistration): Promise<Device> {
const response = await this.api.post<Device>('/api/v1/devices/register', data);
return response.data;
}
async updatePushToken(pushToken: string): Promise<Device> {
const response = await this.api.patch<Device>('/api/v1/devices/push-token', {
pushToken,
});
return response.data;
}
async getDevices(): Promise<DeviceListResponse> {
const response = await this.api.get<DeviceListResponse>('/api/v1/devices');
return response.data;
}
async getDevice(deviceId: string): Promise<Device> {
const response = await this.api.get<Device>(`/api/v1/devices/${deviceId}`);
return response.data;
}
async deleteDevice(deviceId: string): Promise<void> {
await this.api.delete(`/api/v1/devices/${deviceId}`);
}
async getCurrentDevice(): Promise<Device | null> {
try {
const response = await this.api.get<Device>('/api/v1/devices/current');
return response.data;
} catch {
return null;
}
}
}
export const deviceService = new DeviceService();

View File

@@ -0,0 +1,53 @@
/**
* Notification API service
*/
import { getApiClient } from './api-client';
import type { Notification, NotificationListResponse, NotificationPreferences } from '../types';
export class NotificationService {
private api = getApiClient();
async getNotifications(params?: {
page?: number;
limit?: number;
unreadOnly?: boolean;
}): Promise<NotificationListResponse> {
const response = await this.api.get<NotificationListResponse>('/notifications', { params });
return response.data;
}
async getNotification(notificationId: string): Promise<Notification> {
const response = await this.api.get<Notification>(`/notifications/${notificationId}`);
return response.data;
}
async markAsRead(notificationId: string): Promise<void> {
await this.api.patch(`/notifications/${notificationId}/read`);
}
async markAllAsRead(): Promise<void> {
await this.api.post('/notifications/read-all');
}
async deleteNotification(notificationId: string): Promise<void> {
await this.api.delete(`/notifications/${notificationId}`);
}
async getPreferences(): Promise<NotificationPreferences> {
const response = await this.api.get<NotificationPreferences>('/notifications/preferences');
return response.data;
}
async updatePreferences(preferences: NotificationPreferences): Promise<NotificationPreferences> {
const response = await this.api.put<NotificationPreferences>('/notifications/preferences', preferences);
return response.data;
}
async getUnreadCount(): Promise<number> {
const response = await this.api.get<{ count: number }>('/notifications/unread-count');
return response.data.count;
}
}
export const notificationService = new NotificationService();

View File

@@ -0,0 +1,53 @@
/**
* Subscription API service
*/
import { getApiClient } from './api-client';
import type {
Subscription,
SubscriptionTier,
SubscriptionStatusResponse,
CreateSubscriptionRequest,
UpdateSubscriptionRequest,
} from '../types';
export class SubscriptionService {
private api = getApiClient();
async getSubscription(): Promise<SubscriptionStatusResponse> {
const response = await this.api.get<SubscriptionStatusResponse>('/billing/subscription');
return response.data;
}
async createSubscription(data: CreateSubscriptionRequest): Promise<Subscription> {
const response = await this.api.post<Subscription>('/billing/subscription', data);
return response.data;
}
async updateSubscription(data: UpdateSubscriptionRequest): Promise<Subscription> {
const response = await this.api.patch<Subscription>('/billing/subscription', data);
return response.data;
}
async cancelSubscription(): Promise<Subscription> {
const response = await this.api.delete<Subscription>('/billing/subscription');
return response.data;
}
async getTiers(): Promise<SubscriptionTier[]> {
const response = await this.api.get<SubscriptionTier[]>('/billing/tiers');
return response.data;
}
async createCheckoutSession(tier: string): Promise<{ url: string }> {
const response = await this.api.post<{ url: string }>('/billing/checkout', { tier });
return response.data;
}
async createCustomerPortalSession(): Promise<{ url: string }> {
const response = await this.api.post<{ url: string }>('/billing/customer-portal');
return response.data;
}
}
export const subscriptionService = new SubscriptionService();

View File

@@ -0,0 +1,53 @@
/**
* ShieldAI Mobile API Client
*
* A comprehensive TypeScript API client library for React Native apps
* to interact with ShieldAI backend services.
*
* @example
* ```typescript
* import { createApiClient, authService, deviceService } from '@shieldai/mobile-api-client';
*
* // Initialize the client
* createApiClient({
* baseURL: 'https://api.shieldai.freno.me/api/v1',
* timeout: 30000,
* debug: __DEV__,
* });
*
* // Login
* const { user, tokens } = await authService.login({
* email: 'user@example.com',
* password: 'password123',
* });
*
* // Register device for push notifications
* await deviceService.registerDevice({
* platform: 'ios',
* pushToken: '...',
* });
* ```
*/
// Core API client
export {
createApiClient,
getApiClient,
ApiClient,
ApiClientConfig,
} from './api/api-client';
// Services
export { authService, AuthService } from './api/auth.service';
export { deviceService, DeviceService } from './api/device.service';
export { subscriptionService, SubscriptionService } from './api/subscription.service';
export { notificationService, NotificationService } from './api/notification.service';
// Types
export * from './types';
// Storage
export { storage, tokenStorage, StorageAdapter } from './storage/token-storage';
// Utils
export { requestQueue, RequestQueue } from './utils/request-queue';

View File

@@ -0,0 +1,21 @@
// Type declarations for React Native and Expo packages
declare module 'expo-secure-store' {
export function getItemAsync(key: string): Promise<string | null>;
export function setItemAsync(key: string, value: string): Promise<void>;
export function deleteItemAsync(key: string): Promise<void>;
}
declare module '@react-native-async-storage/async-storage' {
export function getItem(key: string): Promise<string | null>;
export function setItem(key: string, value: string): Promise<void>;
export function removeItem(key: string): Promise<void>;
export function clear(): Promise<void>;
}
declare module 'react-native' {
export import Platform = require('react-native/Libraries/Utilities/Platform');
export const Platform: {
OS: 'ios' | 'android' | 'web' | 'windows' | 'macos';
Version: number;
};
}

View File

@@ -0,0 +1,93 @@
/**
* Secure storage for authentication tokens
* Uses expo-secure-store for production, AsyncStorage for fallback
*/
import * as SecureStore from 'expo-secure-store';
import AsyncStorage from '@react-native-async-storage/async-storage';
const ACCESS_TOKEN_KEY = '@shieldai:access_token';
const REFRESH_TOKEN_KEY = '@shieldai:refresh_token';
export interface StorageAdapter {
getItem: (key: string) => Promise<string | null>;
setItem: (key: string, value: string) => Promise<void>;
removeItem: (key: string) => Promise<void>;
}
class SecureStorageAdapter implements StorageAdapter {
async getItem(key: string): Promise<string | null> {
try {
return await SecureStore.getItemAsync(key);
} catch {
// Fallback to AsyncStorage if SecureStore fails
return await AsyncStorage.getItem(key);
}
}
async setItem(key: string, value: string): Promise<void> {
try {
await SecureStore.setItemAsync(key, value);
} catch {
await AsyncStorage.setItem(key, value);
}
}
async removeItem(key: string): Promise<void> {
try {
await SecureStore.deleteItemAsync(key);
} catch {
await AsyncStorage.removeItem(key);
}
}
}
class InMemoryStorageAdapter implements StorageAdapter {
private store: Map<string, string> = new Map();
async getItem(key: string): Promise<string | null> {
return this.store.get(key) || null;
}
async setItem(key: string, value: string): Promise<void> {
this.store.set(key, value);
}
async removeItem(key: string): Promise<void> {
this.store.delete(key);
}
}
// Detect environment and choose appropriate storage
const getStorageAdapter = (): StorageAdapter => {
if (process.env.NODE_ENV === 'test') {
return new InMemoryStorageAdapter();
}
return new SecureStorageAdapter();
};
export const storage = getStorageAdapter();
export const tokenStorage = {
async getAccessToken(): Promise<string | null> {
return await storage.getItem(ACCESS_TOKEN_KEY);
},
async getRefreshToken(): Promise<string | null> {
return await storage.getItem(REFRESH_TOKEN_KEY);
},
async saveTokens(accessToken: string, refreshToken: string): Promise<void> {
await Promise.all([
storage.setItem(ACCESS_TOKEN_KEY, accessToken),
storage.setItem(REFRESH_TOKEN_KEY, refreshToken),
]);
},
async clearTokens(): Promise<void> {
await Promise.all([
storage.removeItem(ACCESS_TOKEN_KEY),
storage.removeItem(REFRESH_TOKEN_KEY),
]);
},
};

View File

@@ -0,0 +1,46 @@
/**
* Authentication types for ShieldAI API
*/
export interface User {
id: string;
email: string;
firstName?: string;
lastName?: string;
createdAt: string;
updatedAt: string;
}
export interface AuthTokens {
accessToken: string;
refreshToken: string;
expiresIn: number;
tokenType: 'Bearer';
}
export interface LoginCredentials {
email: string;
password: string;
}
export interface RegisterData {
email: string;
password: string;
firstName?: string;
lastName?: string;
}
export interface AuthResponse {
user: User;
tokens: AuthTokens;
}
export interface RefreshTokenRequest {
refreshToken: string;
}
export interface AuthError {
code: string;
message: string;
statusCode: number;
}

View File

@@ -0,0 +1,37 @@
/**
* Shared API types
*/
export interface ApiResponse<T> {
data: T;
success: boolean;
message?: string;
}
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
export interface ErrorResponse {
code: string;
message: string;
details?: Record<string, string[]>;
statusCode: number;
}
export interface HealthStatus {
status: 'healthy' | 'degraded' | 'unhealthy';
timestamp: string;
version: string;
services?: Record<string, { status: 'healthy' | 'degraded' | 'unhealthy' }>;
}
export interface VersionInfo {
version: string;
environment: string;
build: string;
}

View File

@@ -0,0 +1,30 @@
/**
* Device types for push notification and device management
*/
export interface Device {
id: string;
userId: string;
platform: 'ios' | 'android';
pushToken?: string;
modelName?: string;
osVersion?: string;
appVersion?: string;
isActive: boolean;
lastActiveAt: string;
createdAt: string;
updatedAt: string;
}
export interface DeviceRegistration {
platform: 'ios' | 'android';
pushToken: string;
modelName?: string;
osVersion?: string;
appVersion?: string;
}
export interface DeviceListResponse {
devices: Device[];
total: number;
}

View File

@@ -0,0 +1,5 @@
export * from './auth.types';
export * from './device.types';
export * from './subscription.types';
export * from './notification.types';
export * from './common.types';

View File

@@ -0,0 +1,27 @@
/**
* Notification types
*/
export interface Notification {
id: string;
userId: string;
type: 'darkwatch_alert' | 'spam_blocked' | 'voiceprint_analysis' | 'subscription' | 'system';
title: string;
message: string;
data?: Record<string, unknown>;
isRead: boolean;
createdAt: string;
readAt?: string;
}
export interface NotificationListResponse {
notifications: Notification[];
total: number;
unreadCount: number;
}
export interface NotificationPreferences {
emailNotifications: boolean;
pushNotifications: boolean;
notificationTypes: Record<string, boolean>;
}

View File

@@ -0,0 +1,49 @@
/**
* Subscription and billing types
*/
export interface Subscription {
id: string;
userId: string;
tier: 'free' | 'basic' | 'premium' | 'enterprise';
status: 'active' | 'canceled' | 'past_due' | 'trialing';
stripeCustomerId: string;
stripeSubscriptionId?: string;
currentPeriodStart: string;
currentPeriodEnd: string;
cancelAtPeriodEnd: boolean;
createdAt: string;
updatedAt: string;
}
export interface SubscriptionTier {
id: string;
name: string;
description: string;
price: number;
currency: string;
interval: 'month' | 'year';
features: string[];
}
export interface CreateSubscriptionRequest {
tier: 'free' | 'basic' | 'premium' | 'enterprise';
paymentMethodId?: string;
}
export interface UpdateSubscriptionRequest {
tier?: 'free' | 'basic' | 'premium' | 'enterprise';
cancelAtPeriodEnd?: boolean;
}
export interface SubscriptionStatusResponse {
subscription: Subscription;
tier: SubscriptionTier;
usage: {
currentPeriod: {
start: string;
end: string;
};
features: Record<string, { used: number; limit: number | null }>;
};
}

View File

@@ -0,0 +1,141 @@
/**
* Request queue for offline support
* Queues API requests when offline and replays when online
*/
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { AxiosRequestConfig, AxiosResponse } from 'axios';
const QUEUE_KEY = '@shieldai:api_queue';
const MAX_QUEUE_SIZE = 100;
export interface QueuedRequest {
id: string;
config: AxiosRequestConfig;
timestamp: number;
retryCount: number;
maxRetries: number;
}
export interface QueueStatus {
size: number;
oldestRequest: number | null;
newestRequest: number | null;
}
export class RequestQueue {
private queue: QueuedRequest[] = [];
private isProcessing = false;
private listeners: Set<() => void> = new Set();
constructor() {
this.loadFromStorage();
}
private notifyListeners(): void {
this.listeners.forEach((listener) => listener());
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
async loadFromStorage(): Promise<void> {
try {
const data = await AsyncStorage.getItem(QUEUE_KEY);
if (data) {
this.queue = JSON.parse(data);
}
} catch (error) {
console.error('Failed to load request queue:', error);
this.queue = [];
}
}
private async saveToStorage(): Promise<void> {
try {
await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(this.queue));
} catch (error) {
console.error('Failed to save request queue:', error);
}
}
async enqueue(config: AxiosRequestConfig): Promise<string> {
const id = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const queuedRequest: QueuedRequest = {
id,
config,
timestamp: Date.now(),
retryCount: 0,
maxRetries: 3,
};
// Limit queue size
if (this.queue.length >= MAX_QUEUE_SIZE) {
this.queue.shift(); // Remove oldest
}
this.queue.push(queuedRequest);
await this.saveToStorage();
this.notifyListeners();
return id;
}
async dequeue(): Promise<QueuedRequest | null> {
if (this.queue.length === 0) return null;
const request = this.queue.shift();
if (request) {
await this.saveToStorage();
this.notifyListeners();
}
return request ?? null;
}
async remove(id: string): Promise<void> {
const index = this.queue.findIndex((r) => r.id === id);
if (index !== -1) {
this.queue.splice(index, 1);
await this.saveToStorage();
this.notifyListeners();
}
}
async retry(id: string): Promise<void> {
const index = this.queue.findIndex((r) => r.id === id);
if (index !== -1) {
this.queue[index].retryCount += 1;
await this.saveToStorage();
this.notifyListeners();
}
}
async clear(): Promise<void> {
this.queue = [];
await AsyncStorage.removeItem(QUEUE_KEY);
this.notifyListeners();
}
getStatus(): QueueStatus {
if (this.queue.length === 0) {
return { size: 0, oldestRequest: null, newestRequest: null };
}
return {
size: this.queue.length,
oldestRequest: this.queue[0]?.timestamp ?? null,
newestRequest: this.queue[this.queue.length - 1]?.timestamp ?? null,
};
}
isOnline(): boolean {
// In React Native, you'd use NetInfo here
// For now, return true (assume online)
return true;
}
}
export const requestQueue = new RequestQueue();

View File

@@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020", "DOM"],
"declaration": true,
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noImplicitThis": true,
"alwaysStrict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": false,
"inlineSourceMap": true,
"inlineSources": true,
"experimentalDecorators": true,
"strictPropertyInitialization": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"moduleResolution": "node",
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["./src/**/*"],
"exclude": ["node_modules", "dist"]
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -404,5 +404,26 @@ describe('data-collector', () => {
expect(result.homeTitleStats).toBeUndefined();
});
it('includes homeTitleStats for WEEKLY_DIGEST', 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([
{ subscriptionId: 'sub-1', type: 'address', isActive: true },
]);
const result = await collectAllReportData(
'user-1', 'sub-1', 'WEEKLY_DIGEST', periodStart, periodEnd
);
expect(result.homeTitleStats).toBeDefined();
expect(result.homeTitleStats?.propertiesMonitored).toBe(1);
expect(result.exposureSummary).toBeDefined();
expect(result.spamStats).toBeDefined();
expect(result.voiceStats).toBeDefined();
});
});
});

View File

@@ -294,7 +294,7 @@ export async function collectAllReportData(
protectionScore,
};
if (reportType === 'ANNUAL_PREMIUM') {
if (reportType === 'ANNUAL_PREMIUM' || reportType === 'WEEKLY_DIGEST') {
payload.homeTitleStats = await collectHomeTitleStats(
subscriptionId,
periodStart,

View File

@@ -316,4 +316,57 @@ describe('ReportService', () => {
);
});
});
describe('scheduleWeeklyDigest', () => {
it('creates weekly digest reports for Premium 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: 'weekly-digest-1' });
const result = await service.scheduleWeeklyDigest();
expect(result.length).toBeGreaterThan(0);
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
reportType: 'WEEKLY_DIGEST',
status: 'PENDING',
}),
})
);
});
it('skips subscriptions that already have a digest for the period', async () => {
mockSubscriptionFindMany.mockResolvedValue([
{ id: 'sub-1', userId: 'user-1', user: { email: 'u1@test.com' } },
]);
mockFindFirst.mockResolvedValue({ id: 'existing-digest' });
const result = await service.scheduleWeeklyDigest();
expect(result).toHaveLength(0);
});
it('only schedules for premium tier subscriptions', async () => {
mockSubscriptionFindMany.mockResolvedValue([
{ id: 'sub-1', userId: 'user-1', user: { email: 'u1@test.com' } },
]);
mockFindFirst.mockResolvedValue(null);
mockCreate.mockResolvedValue({ id: 'weekly-digest-2' });
await service.scheduleWeeklyDigest();
expect(mockSubscriptionFindMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
tier: 'premium',
status: 'active',
}),
})
);
});
});
});

View File

@@ -167,6 +167,55 @@ export class ReportService {
return createdIds;
}
async scheduleWeeklyDigest(): Promise<string[]> {
const premiumSubscriptions = await prisma.subscription.findMany({
where: {
tier: 'premium',
status: 'active',
},
select: {
id: true,
userId: true,
user: { select: { email: true } },
},
});
const createdIds: string[] = [];
const now = new Date();
const periodStart = new Date(now);
periodStart.setDate(periodStart.getDate() - 7);
const periodEnd = new Date(now);
for (const sub of premiumSubscriptions) {
const existing = await prisma.securityReport.findFirst({
where: {
subscriptionId: sub.id,
reportType: 'WEEKLY_DIGEST',
periodStart: periodStart,
periodEnd: periodEnd,
},
});
if (!existing) {
const report = await prisma.securityReport.create({
data: {
userId: sub.userId,
subscriptionId: sub.id,
reportType: 'WEEKLY_DIGEST',
status: 'PENDING',
periodStart,
periodEnd,
title: `Weekly Digest — ${periodStart.toLocaleDateString('en-US', { month: 'long', day: 'numeric' })} to ${periodEnd.toLocaleDateString('en-US', { month: 'long', day: 'numeric' })}`,
scheduledFor: new Date(now.getTime() + 3600000),
},
});
createdIds.push(report.id);
}
}
return createdIds;
}
async scheduleAnnualReports(): Promise<string[]> {
const premiumSubscriptions = await prisma.subscription.findMany({
where: {
@@ -225,6 +274,11 @@ export class ReportService {
if (reportType === 'MONTHLY_PLUS') {
return new Date(now.getFullYear(), now.getMonth() - 1, 1);
}
if (reportType === 'WEEKLY_DIGEST') {
const start = new Date(now);
start.setDate(start.getDate() - 7);
return start;
}
return new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
}
@@ -239,6 +293,9 @@ export class ReportService {
year: 'numeric',
})}`;
}
if (reportType === 'WEEKLY_DIGEST') {
return `Weekly Digest — ${periodStart.toLocaleDateString('en-US', { month: 'long', day: 'numeric' })} to ${periodEnd.toLocaleDateString('en-US', { month: 'long', day: 'numeric' })}`;
}
return `Annual Protection Audit — ${periodStart.getFullYear()}`;
}

View File

@@ -0,0 +1,655 @@
-- CreateEnum
CREATE TYPE "UserRole" AS ENUM ('user', 'family_admin', 'family_member', 'support');
-- CreateEnum
CREATE TYPE "FamilyMemberRole" AS ENUM ('owner', 'admin', 'member');
-- CreateEnum
CREATE TYPE "SubscriptionTier" AS ENUM ('basic', 'plus', 'premium');
-- CreateEnum
CREATE TYPE "SubscriptionStatus" AS ENUM ('active', 'past_due', 'canceled', 'unpaid', 'trialing');
-- CreateEnum
CREATE TYPE "WatchlistType" AS ENUM ('email', 'phoneNumber', 'ssn', 'address', 'domain');
-- CreateEnum
CREATE TYPE "ExposureSource" AS ENUM ('hibp', 'securityTrails', 'censys', 'darkWebForum', 'shodan', 'honeypot');
-- CreateEnum
CREATE TYPE "ExposureSeverity" AS ENUM ('info', 'warning', 'critical');
-- CreateEnum
CREATE TYPE "AlertType" AS ENUM ('exposure_detected', 'exposure_resolved', 'scan_complete', 'subscription_changed', 'system_warning');
-- CreateEnum
CREATE TYPE "AlertSeverity" AS ENUM ('info', 'warning', 'critical');
-- CreateEnum
CREATE TYPE "AlertChannel" AS ENUM ('email', 'push', 'sms');
-- CreateEnum
CREATE TYPE "FeedbackType" AS ENUM ('initial_detection', 'user_confirmation', 'user_rejection', 'auto_learned');
-- CreateEnum
CREATE TYPE "RuleType" AS ENUM ('phoneNumber', 'areaCode', 'prefix', 'pattern', 'reputation');
-- CreateEnum
CREATE TYPE "RuleAction" AS ENUM ('block', 'flag', 'allow', 'challenge');
-- CreateEnum
CREATE TYPE "PropertyChangeType" AS ENUM ('ownership_transfer', 'deed_change', 'lien_filing', 'tax_change', 'assessment_change');
-- CreateEnum
CREATE TYPE "PropertyChangeSeverity" AS ENUM ('info', 'warning', 'critical');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"emailVerified" TIMESTAMP(3),
"name" TEXT,
"image" TEXT,
"role" "UserRole" NOT NULL DEFAULT 'user',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Account" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"providerAccountId" TEXT NOT NULL,
"access_token" TEXT,
"refresh_token" TEXT,
"expires_at" INTEGER,
"token_type" TEXT,
"scope" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"sessionToken" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FamilyGroup" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"ownerId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "FamilyGroup_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FamilyGroupMember" (
"id" TEXT NOT NULL,
"groupId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"role" "FamilyMemberRole" NOT NULL DEFAULT 'member',
"joinedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "FamilyGroupMember_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Subscription" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"familyGroupId" TEXT,
"stripeId" TEXT,
"tier" "SubscriptionTier" NOT NULL DEFAULT 'basic',
"status" "SubscriptionStatus" NOT NULL DEFAULT 'active',
"currentPeriodStart" TIMESTAMP(3) NOT NULL,
"currentPeriodEnd" TIMESTAMP(3) NOT NULL,
"cancelAtPeriodEnd" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Subscription_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "WatchlistItem" (
"id" TEXT NOT NULL,
"subscriptionId" TEXT NOT NULL,
"type" "WatchlistType" NOT NULL,
"value" TEXT NOT NULL,
"hash" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WatchlistItem_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Exposure" (
"id" TEXT NOT NULL,
"subscriptionId" TEXT NOT NULL,
"watchlistItemId" TEXT,
"source" "ExposureSource" NOT NULL,
"dataType" "WatchlistType" NOT NULL,
"identifier" TEXT NOT NULL,
"identifierHash" TEXT NOT NULL,
"severity" "ExposureSeverity" NOT NULL DEFAULT 'info',
"metadata" JSONB,
"isFirstTime" BOOLEAN NOT NULL DEFAULT false,
"detectedAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Exposure_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Alert" (
"id" TEXT NOT NULL,
"subscriptionId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"exposureId" TEXT,
"type" "AlertType" NOT NULL,
"title" TEXT NOT NULL,
"message" TEXT NOT NULL,
"severity" "AlertSeverity" NOT NULL DEFAULT 'info',
"isRead" BOOLEAN NOT NULL DEFAULT false,
"readAt" TIMESTAMP(3),
"channel" "AlertChannel"[],
"propertyChangeId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Alert_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VoiceEnrollment" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"voiceHash" TEXT NOT NULL,
"audioMetadata" JSONB,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "VoiceEnrollment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VoiceAnalysis" (
"id" TEXT NOT NULL,
"enrollmentId" TEXT,
"userId" TEXT NOT NULL,
"audioHash" TEXT NOT NULL,
"isSynthetic" BOOLEAN NOT NULL,
"confidence" DOUBLE PRECISION NOT NULL,
"analysisResult" JSONB NOT NULL,
"audioUrl" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "VoiceAnalysis_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SpamFeedback" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"phoneNumber" TEXT NOT NULL,
"phoneNumberHash" TEXT NOT NULL,
"isSpam" BOOLEAN NOT NULL,
"confidence" DOUBLE PRECISION,
"feedbackType" "FeedbackType" NOT NULL,
"metadata" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SpamFeedback_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SpamRule" (
"id" TEXT NOT NULL,
"userId" TEXT,
"isGlobal" BOOLEAN NOT NULL DEFAULT false,
"ruleType" "RuleType" NOT NULL,
"pattern" TEXT NOT NULL,
"action" "RuleAction" NOT NULL,
"priority" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SpamRule_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AuditLog" (
"id" TEXT NOT NULL,
"userId" TEXT,
"action" TEXT NOT NULL,
"resource" TEXT NOT NULL,
"resourceId" TEXT,
"changes" JSONB,
"metadata" JSONB,
"ipAddress" TEXT,
"userAgent" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "KPISnapshot" (
"id" TEXT NOT NULL,
"date" TIMESTAMP(3) NOT NULL,
"metricName" TEXT NOT NULL,
"metricValue" DOUBLE PRECISION NOT NULL,
"metadata" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "KPISnapshot_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "WaitlistEntry" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT,
"source" TEXT,
"tier" "SubscriptionTier",
"utmSource" TEXT,
"utmMedium" TEXT,
"utmCampaign" TEXT,
"metadata" JSONB,
"convertedAt" TIMESTAMP(3),
"convertedToUserId" TEXT,
"convertedToSubscriptionId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WaitlistEntry_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BlogPost" (
"id" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"title" TEXT NOT NULL,
"excerpt" TEXT,
"content" TEXT NOT NULL,
"authorName" TEXT,
"coverImageUrl" TEXT,
"tags" TEXT[],
"published" BOOLEAN NOT NULL DEFAULT false,
"publishedAt" TIMESTAMP(3),
"viewCount" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BlogPost_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PropertyWatchlistItem" (
"id" TEXT NOT NULL,
"subscriptionId" TEXT NOT NULL,
"address" TEXT NOT NULL,
"parcelId" TEXT NOT NULL,
"ownerName" TEXT NOT NULL,
"streetAddress" TEXT NOT NULL,
"city" TEXT NOT NULL,
"state" TEXT NOT NULL,
"zipCode" TEXT NOT NULL,
"latitude" DOUBLE PRECISION,
"longitude" DOUBLE PRECISION,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PropertyWatchlistItem_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PropertySnapshot" (
"id" TEXT NOT NULL,
"propertyWatchlistItemId" TEXT NOT NULL,
"subscriptionId" TEXT NOT NULL,
"ownerName" TEXT NOT NULL,
"ownerAddress" TEXT,
"assessmentValue" INTEGER,
"assessedYear" INTEGER,
"taxAmount" DOUBLE PRECISION,
"taxYear" INTEGER,
"lienData" JSONB,
"deedData" JSONB,
"metadata" JSONB,
"snapshotDate" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PropertySnapshot_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PropertyChange" (
"id" TEXT NOT NULL,
"propertyWatchlistItemId" TEXT,
"subscriptionId" TEXT NOT NULL,
"snapshotId" TEXT,
"changeType" "PropertyChangeType" NOT NULL,
"severity" "PropertyChangeSeverity" NOT NULL DEFAULT 'info',
"previousValue" JSONB,
"newValue" JSONB,
"diff" JSONB,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL,
"isResolved" BOOLEAN NOT NULL DEFAULT false,
"resolvedAt" TIMESTAMP(3),
"resolvedByUserId" TEXT,
"detectedAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PropertyChange_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE INDEX "User_email_idx" ON "User"("email");
-- CreateIndex
CREATE INDEX "User_role_idx" ON "User"("role");
-- CreateIndex
CREATE INDEX "Account_userId_idx" ON "Account"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "Account_userId_provider_providerAccountId_key" ON "Account"("userId", "provider", "providerAccountId");
-- CreateIndex
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
-- CreateIndex
CREATE INDEX "Session_sessionToken_idx" ON "Session"("sessionToken");
-- CreateIndex
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
-- CreateIndex
CREATE INDEX "FamilyGroup_ownerId_idx" ON "FamilyGroup"("ownerId");
-- CreateIndex
CREATE INDEX "FamilyGroup_name_idx" ON "FamilyGroup"("name");
-- CreateIndex
CREATE INDEX "FamilyGroupMember_groupId_idx" ON "FamilyGroupMember"("groupId");
-- CreateIndex
CREATE INDEX "FamilyGroupMember_userId_idx" ON "FamilyGroupMember"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "FamilyGroupMember_groupId_userId_key" ON "FamilyGroupMember"("groupId", "userId");
-- CreateIndex
CREATE UNIQUE INDEX "Subscription_stripeId_key" ON "Subscription"("stripeId");
-- CreateIndex
CREATE INDEX "Subscription_userId_idx" ON "Subscription"("userId");
-- CreateIndex
CREATE INDEX "Subscription_familyGroupId_idx" ON "Subscription"("familyGroupId");
-- CreateIndex
CREATE INDEX "Subscription_stripeId_idx" ON "Subscription"("stripeId");
-- CreateIndex
CREATE INDEX "Subscription_tier_idx" ON "Subscription"("tier");
-- CreateIndex
CREATE INDEX "WatchlistItem_subscriptionId_idx" ON "WatchlistItem"("subscriptionId");
-- CreateIndex
CREATE INDEX "WatchlistItem_type_idx" ON "WatchlistItem"("type");
-- CreateIndex
CREATE INDEX "WatchlistItem_hash_idx" ON "WatchlistItem"("hash");
-- CreateIndex
CREATE UNIQUE INDEX "WatchlistItem_subscriptionId_type_hash_key" ON "WatchlistItem"("subscriptionId", "type", "hash");
-- CreateIndex
CREATE INDEX "Exposure_subscriptionId_idx" ON "Exposure"("subscriptionId");
-- CreateIndex
CREATE INDEX "Exposure_watchlistItemId_idx" ON "Exposure"("watchlistItemId");
-- CreateIndex
CREATE INDEX "Exposure_source_idx" ON "Exposure"("source");
-- CreateIndex
CREATE INDEX "Exposure_severity_idx" ON "Exposure"("severity");
-- CreateIndex
CREATE INDEX "Exposure_detectedAt_idx" ON "Exposure"("detectedAt");
-- CreateIndex
CREATE INDEX "Alert_subscriptionId_idx" ON "Alert"("subscriptionId");
-- CreateIndex
CREATE INDEX "Alert_userId_idx" ON "Alert"("userId");
-- CreateIndex
CREATE INDEX "Alert_isRead_idx" ON "Alert"("isRead");
-- CreateIndex
CREATE INDEX "Alert_createdAt_idx" ON "Alert"("createdAt");
-- CreateIndex
CREATE INDEX "VoiceEnrollment_userId_idx" ON "VoiceEnrollment"("userId");
-- CreateIndex
CREATE INDEX "VoiceEnrollment_voiceHash_idx" ON "VoiceEnrollment"("voiceHash");
-- CreateIndex
CREATE INDEX "VoiceAnalysis_userId_idx" ON "VoiceAnalysis"("userId");
-- CreateIndex
CREATE INDEX "VoiceAnalysis_enrollmentId_idx" ON "VoiceAnalysis"("enrollmentId");
-- CreateIndex
CREATE INDEX "VoiceAnalysis_audioHash_idx" ON "VoiceAnalysis"("audioHash");
-- CreateIndex
CREATE INDEX "SpamFeedback_userId_idx" ON "SpamFeedback"("userId");
-- CreateIndex
CREATE INDEX "SpamFeedback_phoneNumberHash_idx" ON "SpamFeedback"("phoneNumberHash");
-- CreateIndex
CREATE INDEX "SpamFeedback_isSpam_idx" ON "SpamFeedback"("isSpam");
-- CreateIndex
CREATE INDEX "SpamRule_userId_idx" ON "SpamRule"("userId");
-- CreateIndex
CREATE INDEX "SpamRule_isGlobal_idx" ON "SpamRule"("isGlobal");
-- CreateIndex
CREATE INDEX "SpamRule_ruleType_idx" ON "SpamRule"("ruleType");
-- CreateIndex
CREATE INDEX "AuditLog_userId_idx" ON "AuditLog"("userId");
-- CreateIndex
CREATE INDEX "AuditLog_action_idx" ON "AuditLog"("action");
-- CreateIndex
CREATE INDEX "AuditLog_resource_idx" ON "AuditLog"("resource");
-- CreateIndex
CREATE INDEX "AuditLog_createdAt_idx" ON "AuditLog"("createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "KPISnapshot_date_key" ON "KPISnapshot"("date");
-- CreateIndex
CREATE INDEX "KPISnapshot_metricName_idx" ON "KPISnapshot"("metricName");
-- CreateIndex
CREATE INDEX "KPISnapshot_date_idx" ON "KPISnapshot"("date");
-- CreateIndex
CREATE INDEX "WaitlistEntry_email_idx" ON "WaitlistEntry"("email");
-- CreateIndex
CREATE INDEX "WaitlistEntry_source_idx" ON "WaitlistEntry"("source");
-- CreateIndex
CREATE INDEX "WaitlistEntry_createdAt_idx" ON "WaitlistEntry"("createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "BlogPost_slug_key" ON "BlogPost"("slug");
-- CreateIndex
CREATE INDEX "BlogPost_slug_idx" ON "BlogPost"("slug");
-- CreateIndex
CREATE INDEX "BlogPost_published_publishedAt_idx" ON "BlogPost"("published", "publishedAt");
-- CreateIndex
CREATE INDEX "BlogPost_tags_idx" ON "BlogPost"("tags");
-- CreateIndex
CREATE INDEX "PropertyWatchlistItem_subscriptionId_idx" ON "PropertyWatchlistItem"("subscriptionId");
-- CreateIndex
CREATE INDEX "PropertyWatchlistItem_parcelId_idx" ON "PropertyWatchlistItem"("parcelId");
-- CreateIndex
CREATE INDEX "PropertyWatchlistItem_address_idx" ON "PropertyWatchlistItem"("address");
-- CreateIndex
CREATE UNIQUE INDEX "PropertyWatchlistItem_subscriptionId_parcelId_key" ON "PropertyWatchlistItem"("subscriptionId", "parcelId");
-- CreateIndex
CREATE INDEX "PropertySnapshot_propertyWatchlistItemId_idx" ON "PropertySnapshot"("propertyWatchlistItemId");
-- CreateIndex
CREATE INDEX "PropertySnapshot_subscriptionId_idx" ON "PropertySnapshot"("subscriptionId");
-- CreateIndex
CREATE INDEX "PropertySnapshot_snapshotDate_idx" ON "PropertySnapshot"("snapshotDate");
-- CreateIndex
CREATE INDEX "PropertyChange_propertyWatchlistItemId_idx" ON "PropertyChange"("propertyWatchlistItemId");
-- CreateIndex
CREATE INDEX "PropertyChange_subscriptionId_idx" ON "PropertyChange"("subscriptionId");
-- CreateIndex
CREATE INDEX "PropertyChange_changeType_idx" ON "PropertyChange"("changeType");
-- CreateIndex
CREATE INDEX "PropertyChange_severity_idx" ON "PropertyChange"("severity");
-- CreateIndex
CREATE INDEX "PropertyChange_detectedAt_idx" ON "PropertyChange"("detectedAt");
-- CreateIndex
CREATE INDEX "PropertyChange_isResolved_idx" ON "PropertyChange"("isResolved");
-- AddForeignKey
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FamilyGroup" ADD CONSTRAINT "FamilyGroup_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FamilyGroupMember" ADD CONSTRAINT "FamilyGroupMember_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "FamilyGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FamilyGroupMember" ADD CONSTRAINT "FamilyGroupMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_familyGroupId_fkey" FOREIGN KEY ("familyGroupId") REFERENCES "FamilyGroup"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WatchlistItem" ADD CONSTRAINT "WatchlistItem_subscriptionId_fkey" FOREIGN KEY ("subscriptionId") REFERENCES "Subscription"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Exposure" ADD CONSTRAINT "Exposure_subscriptionId_fkey" FOREIGN KEY ("subscriptionId") REFERENCES "Subscription"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Exposure" ADD CONSTRAINT "Exposure_watchlistItemId_fkey" FOREIGN KEY ("watchlistItemId") REFERENCES "WatchlistItem"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Alert" ADD CONSTRAINT "Alert_subscriptionId_fkey" FOREIGN KEY ("subscriptionId") REFERENCES "Subscription"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Alert" ADD CONSTRAINT "Alert_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Alert" ADD CONSTRAINT "Alert_exposureId_fkey" FOREIGN KEY ("exposureId") REFERENCES "Exposure"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Alert" ADD CONSTRAINT "Alert_propertyChangeId_fkey" FOREIGN KEY ("propertyChangeId") REFERENCES "PropertyChange"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "VoiceEnrollment" ADD CONSTRAINT "VoiceEnrollment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "VoiceAnalysis" ADD CONSTRAINT "VoiceAnalysis_enrollmentId_fkey" FOREIGN KEY ("enrollmentId") REFERENCES "VoiceEnrollment"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "VoiceAnalysis" ADD CONSTRAINT "VoiceAnalysis_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SpamFeedback" ADD CONSTRAINT "SpamFeedback_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SpamRule" ADD CONSTRAINT "SpamRule_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PropertyWatchlistItem" ADD CONSTRAINT "PropertyWatchlistItem_subscriptionId_fkey" FOREIGN KEY ("subscriptionId") REFERENCES "Subscription"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PropertySnapshot" ADD CONSTRAINT "PropertySnapshot_propertyWatchlistItemId_fkey" FOREIGN KEY ("propertyWatchlistItemId") REFERENCES "PropertyWatchlistItem"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PropertySnapshot" ADD CONSTRAINT "PropertySnapshot_subscriptionId_fkey" FOREIGN KEY ("subscriptionId") REFERENCES "Subscription"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PropertyChange" ADD CONSTRAINT "PropertyChange_propertyWatchlistItemId_fkey" FOREIGN KEY ("propertyWatchlistItemId") REFERENCES "PropertyWatchlistItem"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PropertyChange" ADD CONSTRAINT "PropertyChange_subscriptionId_fkey" FOREIGN KEY ("subscriptionId") REFERENCES "Subscription"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PropertyChange" ADD CONSTRAINT "PropertyChange_snapshotId_fkey" FOREIGN KEY ("snapshotId") REFERENCES "PropertySnapshot"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

View File

@@ -21,22 +21,22 @@ model User {
name String?
image String?
role UserRole @default(user)
// Relationships
accounts Account[]
sessions Session[]
familyGroups FamilyGroupMember[]
familyGroupOwned FamilyGroup[] @relation("FamilyGroupOwner")
subscriptions Subscription[]
alerts Alert[]
accounts Account[]
sessions Session[]
familyGroups FamilyGroupMember[]
familyGroupOwned FamilyGroup[] @relation("FamilyGroupOwner")
subscriptions Subscription[]
alerts Alert[]
voiceEnrollments VoiceEnrollment[]
voiceAnalyses VoiceAnalysis[]
spamFeedback SpamFeedback[]
spamRules SpamRule[]
voiceAnalyses VoiceAnalysis[]
spamFeedback SpamFeedback[]
spamRules SpamRule[]
// Audit
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([email])
@@index([role])
@@ -50,7 +50,7 @@ enum UserRole {
}
model Account {
id String @id @default(uuid())
id String @id @default(uuid())
userId String
provider String
providerAccountId String
@@ -59,11 +59,11 @@ model Account {
expires_at Int?
token_type String?
scope String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId, provider, providerAccountId])
@@index([userId])
@@ -74,11 +74,11 @@ model Session {
userId String
sessionToken String @unique
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([sessionToken])
@@index([userId])
@@ -89,30 +89,30 @@ model Session {
// ============================================
model FamilyGroup {
id String @id @default(uuid())
name String
ownerId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
owner User @relation("FamilyGroupOwner", fields: [ownerId], references: [id])
members FamilyGroupMember[]
id String @id @default(uuid())
name String
ownerId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
owner User @relation("FamilyGroupOwner", fields: [ownerId], references: [id])
members FamilyGroupMember[]
subscriptions Subscription[]
@@index([ownerId])
@@index([name])
}
model FamilyGroupMember {
id String @id @default(uuid())
groupId String
userId String
role FamilyMemberRole @default(member)
joinedAt DateTime @default(now())
group FamilyGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
id String @id @default(uuid())
groupId String
userId String
role FamilyMemberRole @default(member)
joinedAt DateTime @default(now())
group FamilyGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -128,25 +128,28 @@ enum FamilyMemberRole {
}
model Subscription {
id String @id @default(uuid())
userId String
familyGroupId String?
stripeId String? @unique
tier SubscriptionTier @default(basic)
status SubscriptionStatus @default(active)
id String @id @default(uuid())
userId String
familyGroupId String?
stripeId String? @unique
tier SubscriptionTier @default(basic)
status SubscriptionStatus @default(active)
currentPeriodStart DateTime
currentPeriodEnd DateTime
cancelAtPeriodEnd Boolean @default(false)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
familyGroup FamilyGroup? @relation(fields: [familyGroupId], references: [id])
watchlistItems WatchlistItem[]
exposures Exposure[]
alerts Alert[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
currentPeriodEnd DateTime
cancelAtPeriodEnd Boolean @default(false)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
familyGroup FamilyGroup? @relation(fields: [familyGroupId], references: [id])
watchlistItems WatchlistItem[]
exposures Exposure[]
alerts Alert[]
propertyWatchlistItems PropertyWatchlistItem[]
propertySnapshots PropertySnapshot[]
propertyChanges PropertyChange[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([familyGroupId])
@@ -173,18 +176,18 @@ enum SubscriptionStatus {
// ============================================
model WatchlistItem {
id String @id @default(uuid())
id String @id @default(uuid())
subscriptionId String
type WatchlistType
value String
hash String // SHA-256 hash for deduplication
isActive Boolean @default(true)
subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
exposures Exposure[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
type WatchlistType
value String
hash String // SHA-256 hash for deduplication
isActive Boolean @default(true)
subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
exposures Exposure[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([subscriptionId, type, hash])
@@index([subscriptionId])
@@ -201,7 +204,7 @@ enum WatchlistType {
}
model Exposure {
id String @id @default(uuid())
id String @id @default(uuid())
subscriptionId String
watchlistItemId String?
source ExposureSource
@@ -209,16 +212,16 @@ model Exposure {
identifier String
identifierHash String
severity ExposureSeverity @default(info)
metadata Json? // Additional source-specific data
isFirstTime Boolean @default(false)
subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
watchlistItem WatchlistItem? @relation(fields: [watchlistItemId], references: [id])
alerts Alert[]
detectedAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
metadata Json? // Additional source-specific data
isFirstTime Boolean @default(false)
subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
watchlistItem WatchlistItem? @relation(fields: [watchlistItemId], references: [id])
alerts Alert[]
detectedAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([subscriptionId])
@@index([watchlistItemId])
@@ -228,7 +231,7 @@ model Exposure {
}
enum ExposureSource {
hibp // Have I Been Pwned
hibp // Have I Been Pwned
securityTrails
censys
darkWebForum
@@ -247,24 +250,27 @@ enum ExposureSeverity {
// ============================================
model Alert {
id String @id @default(uuid())
subscriptionId String
userId String
exposureId String?
type AlertType
title String
message String
severity AlertSeverity @default(info)
isRead Boolean @default(false)
readAt DateTime?
channel AlertChannel[] // Array of notification channels
subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
exposure Exposure? @relation(fields: [exposureId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(uuid())
subscriptionId String
userId String
exposureId String?
type AlertType
title String
message String
severity AlertSeverity @default(info)
isRead Boolean @default(false)
readAt DateTime?
channel AlertChannel[] // Array of notification channels
subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
exposure Exposure? @relation(fields: [exposureId], references: [id])
propertyChange PropertyChange? @relation("PropertyAlerts", fields: [propertyChangeId], references: [id])
propertyChangeId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([subscriptionId])
@@index([userId])
@@ -297,37 +303,37 @@ enum AlertChannel {
// ============================================
model VoiceEnrollment {
id String @id @default(uuid())
id String @id @default(uuid())
userId String
name String
voiceHash String // FAISS embedding hash
audioMetadata Json? // Sample rate, duration, etc.
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
analyses VoiceAnalysis[]
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
voiceHash String // FAISS embedding hash
audioMetadata Json? // Sample rate, duration, etc.
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
analyses VoiceAnalysis[]
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([voiceHash])
}
model VoiceAnalysis {
id String @id @default(uuid())
enrollmentId String?
userId String
audioHash String // Content hash of audio file
isSynthetic Boolean
confidence Float // 0.0 to 1.0
analysisResult Json // Full ML analysis results
audioUrl String // S3 storage URL
enrollment VoiceEnrollment? @relation(fields: [enrollmentId], references: [id])
user User @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
id String @id @default(uuid())
enrollmentId String?
userId String
audioHash String // Content hash of audio file
isSynthetic Boolean
confidence Float // 0.0 to 1.0
analysisResult Json // Full ML analysis results
audioUrl String // S3 storage URL
enrollment VoiceEnrollment? @relation(fields: [enrollmentId], references: [id])
user User @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
@@index([userId])
@@index([enrollmentId])
@@ -339,19 +345,19 @@ model VoiceAnalysis {
// ============================================
model SpamFeedback {
id String @id @default(uuid())
userId String
phoneNumber String
phoneNumberHash String // SHA-256 hash
isSpam Boolean
confidence Float? // ML model confidence
feedbackType FeedbackType
metadata Json? // Call duration, time, etc.
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(uuid())
userId String
phoneNumber String
phoneNumberHash String // SHA-256 hash
isSpam Boolean
confidence Float? // ML model confidence
feedbackType FeedbackType
metadata Json? // Call duration, time, etc.
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([phoneNumberHash])
@@ -366,19 +372,19 @@ enum FeedbackType {
}
model SpamRule {
id String @id @default(uuid())
userId String?
isGlobal Boolean @default(false)
ruleType RuleType
pattern String
action RuleAction
priority Int @default(0)
isActive Boolean @default(true)
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(uuid())
userId String?
isGlobal Boolean @default(false)
ruleType RuleType
pattern String
action RuleAction
priority Int @default(0)
isActive Boolean @default(true)
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([isGlobal])
@@ -405,16 +411,16 @@ enum RuleAction {
// ============================================
model AuditLog {
id String @id @default(uuid())
userId String?
action String
resource String
id String @id @default(uuid())
userId String?
action String
resource String
resourceId String?
changes Json? // Before/after values
metadata Json?
ipAddress String?
userAgent String?
changes Json? // Before/after values
metadata Json?
ipAddress String?
userAgent String?
createdAt DateTime @default(now())
@@index([userId])
@@ -429,8 +435,8 @@ model KPISnapshot {
metricName String
metricValue Float
metadata Json?
createdAt DateTime @default(now())
createdAt DateTime @default(now())
@@index([metricName])
@@index([date])
@@ -441,23 +447,23 @@ model KPISnapshot {
// ============================================
model WaitlistEntry {
id String @id @default(uuid())
email String
name String?
source String? // landing_page, blog, referral, social, paid_search
tier SubscriptionTier? // interest level
utmSource String?
utmMedium String?
utmCampaign String?
metadata Json? // Browser, device, location, etc.
id String @id @default(uuid())
email String
name String?
source String? // landing_page, blog, referral, social, paid_search
tier SubscriptionTier? // interest level
utmSource String?
utmMedium String?
utmCampaign String?
metadata Json? // Browser, device, location, etc.
// Conversion tracking
convertedAt DateTime?
convertedToUserId String?
convertedAt DateTime?
convertedToUserId String?
convertedToSubscriptionId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([email])
@@index([source])
@@ -465,22 +471,127 @@ model WaitlistEntry {
}
model BlogPost {
id String @id @default(uuid())
slug String @unique
id String @id @default(uuid())
slug String @unique
title String
excerpt String?
content String
authorName String?
coverImageUrl String?
tags String[] // Array of tag strings
published Boolean @default(false)
tags String[] // Array of tag strings
published Boolean @default(false)
publishedAt DateTime?
viewCount Int @default(0)
viewCount Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([slug])
@@index([published, publishedAt])
@@index([tags])
}
// ============================================
// Home Title Property Monitoring Models
// ============================================
model PropertyWatchlistItem {
id String @id @default(uuid())
subscriptionId String
address String
parcelId String
ownerName String
streetAddress String
city String
state String
zipCode String
latitude Float?
longitude Float?
isActive Boolean @default(true)
subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
snapshots PropertySnapshot[]
changes PropertyChange[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([subscriptionId, parcelId])
@@index([subscriptionId])
@@index([parcelId])
@@index([address])
}
model PropertySnapshot {
id String @id @default(uuid())
propertyWatchlistItemId String
subscriptionId String
ownerName String
ownerAddress String?
assessmentValue Int?
assessedYear Int?
taxAmount Float?
taxYear Int?
lienData Json? // Array of liens with amounts, types, dates
deedData Json? // Latest deed information
metadata Json? // Additional property data from sources
propertyWatchlistItem PropertyWatchlistItem @relation(fields: [propertyWatchlistItemId], references: [id], onDelete: Cascade)
subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
changes PropertyChange[] @relation("SnapshotChanges")
snapshotDate DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([propertyWatchlistItemId])
@@index([subscriptionId])
@@index([snapshotDate])
}
model PropertyChange {
id String @id @default(uuid())
propertyWatchlistItemId String?
subscriptionId String
snapshotId String? // Reference to the snapshot where change was detected
changeType PropertyChangeType
severity PropertyChangeSeverity @default(info)
previousValue Json? // Before state
newValue Json? // After state
diff Json? // Computed diff between states
title String // Short description
description String // Detailed explanation
isResolved Boolean @default(false)
resolvedAt DateTime?
resolvedByUserId String?
propertyWatchlistItem PropertyWatchlistItem? @relation(fields: [propertyWatchlistItemId], references: [id])
subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
snapshot PropertySnapshot? @relation("SnapshotChanges", fields: [snapshotId], references: [id])
alerts Alert[] @relation("PropertyAlerts")
detectedAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([propertyWatchlistItemId])
@@index([subscriptionId])
@@index([changeType])
@@index([severity])
@@index([detectedAt])
@@index([isResolved])
}
enum PropertyChangeType {
ownership_transfer
deed_change
lien_filing
tax_change
assessment_change
}
enum PropertyChangeSeverity {
info
warning
critical
}

View File

@@ -1,5 +1,5 @@
// Re-export Prisma client
export { prisma } from './client';
export { prisma } from './client.js';
// Export types
export type {
@@ -18,4 +18,4 @@ export type {
SpamRule,
AuditLog,
KPISnapshot,
} from './client';
} from './client.js';

View File

@@ -1,6 +1,7 @@
export { EmailService } from './services/email.service';
export { SMSService } from './services/sms.service';
export { PushService } from './services/push.service';
export { APNSService } from './services/apns.service';
export { NotificationService, RateLimitResult } from './services/notification.service';
export { RedisService } from './services/redis.service';
export { TemplateService } from './services/template.service';

View File

@@ -66,7 +66,7 @@ export class EmailService {
data: att.content,
contentType: att.mimeType,
})),
});
} as any);
if (error) {
return {

View File

@@ -45,7 +45,7 @@ export class SMSService {
from: notification.from || config.twilio.messagingServiceSid,
to: notification.to,
metadata: notification.metadata,
});
} as any);
this.sentCount.set(rateLimitKey, currentCount + 1);

View File

@@ -120,6 +120,67 @@ export const DefaultEmailTemplates: TemplateDefinition[] = [
{ name: 'pdf_url', type: 'string', required: true },
],
},
{
id: 'weekly_digest',
name: 'Weekly Digest',
channel: 'email',
locale: 'en',
category: 'report',
subject: 'Your ShieldAI Weekly Digest — {{period_start}} to {{period_end}}',
body: 'Hi {{name}},\n\nHere is your ShieldAI Weekly Digest for {{period_start}} to {{period_end}}.\n\nProtection Score: {{protection_score}}/100\nNew Exposures: {{new_exposures}}\nSpam Events Blocked: {{spam_events_blocked}}\nVoice Threats Detected: {{voice_threats}}\nProperties Monitored: {{properties_monitored}}\n\nView your full report: {{report_url}}\n\nBest regards,\nThe ShieldAI Team',
htmlBody: `
<h2>Your ShieldAI Weekly Digest</h2>
<p>Hi {{name}},</p>
<p>Here is your weekly protection summary for <strong>{{period_start}} to {{period_end}}</strong>.</p>
<h3>Protection Score</h3>
<p>Your protection score is <strong>{{protection_score}}/100</strong>.</p>
<h3>Exposure Summary</h3>
<ul>
<li>New exposures: {{new_exposures}}</li>
<li>Critical exposures: {{critical_exposures}}</li>
</ul>
<h3>Spam Shield</h3>
<ul>
<li>Spam events blocked: {{spam_events_blocked}}</li>
<li>Calls blocked: {{calls_blocked}}</li>
<li>Texts blocked: {{texts_blocked}}</li>
</ul>
<h3>VoicePrint</h3>
<ul>
<li>Voice threats detected: {{voice_threats}}</li>
<li>Active enrollments: {{enrollments_active}}</li>
</ul>
<h3>Home Title Monitoring</h3>
<ul>
<li>Properties monitored: {{properties_monitored}}</li>
<li>Changes detected: {{changes_detected}}</li>
</ul>
<p><a href="{{report_url}}">View Full Report</a></p>
<p>Best regards,<br>The ShieldAI Team</p>
`,
variables: [
{ name: 'name', type: 'string', required: true },
{ name: 'period_start', type: 'string', required: true },
{ name: 'period_end', type: 'string', required: true },
{ name: 'protection_score', type: 'number', required: true },
{ name: 'new_exposures', type: 'number', required: false, defaultValue: '0' },
{ name: 'critical_exposures', type: 'number', required: false, defaultValue: '0' },
{ name: 'spam_events_blocked', type: 'number', required: false, defaultValue: '0' },
{ name: 'calls_blocked', type: 'number', required: false, defaultValue: '0' },
{ name: 'texts_blocked', type: 'number', required: false, defaultValue: '0' },
{ name: 'voice_threats', type: 'number', required: false, defaultValue: '0' },
{ name: 'enrollments_active', type: 'number', required: false, defaultValue: '0' },
{ name: 'properties_monitored', type: 'number', required: false, defaultValue: '0' },
{ name: 'changes_detected', type: 'number', required: false, defaultValue: '0' },
{ name: 'report_url', type: 'string', required: true },
],
},
];
export const DefaultSMSTemplates: TemplateDefinition[] = [