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

@@ -0,0 +1,389 @@
# Push Notifications - Mobile App Quick Start
This guide helps you integrate push notifications into the ShieldAI React Native mobile app.
## Prerequisites
Before starting, ensure:
- ✅ Backend push notification infrastructure is deployed
- ✅ Firebase project is set up (for Android)
- ✅ Apple Developer account is active (for iOS)
- ✅ Environment variables are configured on the backend
## Step 1: Install Dependencies
```bash
npm install @react-native-firebase/app @react-native-firebase/messaging
```
## Step 2: Android Setup
### 2.1 Add Firebase to Android Project
1. Go to [Firebase Console](https://console.firebase.google.com)
2. Select your ShieldAI project
3. Add Android app with package name: `com.shieldai.mobile`
4. Download `google-services.json`
5. Place it in `android/app/google-services.json`
### 2.2 Update Build Configuration
In `android/build.gradle`:
```gradle
buildscript {
dependencies {
classpath 'com.google.gms:google-services:4.4.0'
}
}
```
In `android/app/build.gradle`:
```gradle
apply plugin: 'com.google.gms.google-services'
```
### 2.3 Request Permissions
In `AndroidManifest.xml`:
```xml
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.VIBRATE" />
```
## Step 3: iOS Setup
### 3.1 Enable Push Notifications
1. Open Xcode project
2. Select your app target
3. Go to "Signing & Capabilities"
4. Click "+ Capability"
5. Add "Push Notifications"
6. Add "Background Modes" and check "Remote notifications"
### 3.2 Configure Firebase
1. Download `GoogleService-Info.plist` from Firebase Console
2. Add it to your Xcode project (drag to project folder)
3. Ensure "Copy items if needed" is checked
### 3.3 Add Import in AppDelegate.swift
```swift
import FirebaseMessaging
import UserNotifications
@main
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
FirebaseApp.configure()
Messaging.messaging().delegate = self
return true
}
// Request permission
func requestAuthorization() {
UNUserNotificationCenter.current().delegate = self
let authOptions: UNAuthorizationOptions = [.alert, .badge, .sound]
UNUserNotificationCenter.current().requestAuthorization(
options: authOptions,
completionHandler: { granted, error in
if granted {
DispatchQueue.main.async {
UIApplication.shared.registerForRemoteNotifications()
}
}
}
)
}
// Handle token refresh
func messaging(_ messaging: Messaging, didRefreshRegistrationToken fcmToken: String) {
print("FCM token refreshed: \(fcmToken)")
// Send new token to backend
registerDeviceToken(fcmToken)
}
// Handle foreground notifications
func userNotificationCenter(_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler:
@escaping (UNNotificationPresentationOptions) -> Void) {
let userInfo = notification.request.content.userInfo
print("Foreground notification: \(userInfo)")
completionHandler([.banner, .sound, .badge])
}
// Handle notification tap
func userNotificationCenter(_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void) {
let userInfo = response.notification.request.content.userInfo
print("Notification tapped: \(userInfo)")
completionHandler()
}
}
extension AppDelegate: MessagingDelegate {
func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) {
print("FCM token received: \(fcmToken ?? "none")")
if let token = fcmToken {
registerDeviceToken(token)
}
}
}
// Helper function to register with backend
func registerDeviceToken(_ token: String) {
// Call your API to register the device
// POST /api/v1/devices/register
// Body: { userId, platform: "ios", token, deviceType: "mobile" }
}
```
## Step 4: React Native Integration
### 4.1 Create Notification Service
Create `src/services/NotificationService.ts`:
```typescript
import messaging from '@react-native-firebase/messaging';
import AsyncStorage from '@react-native-async-storage/async-storage';
import axios from 'axios';
const API_BASE_URL = 'https://api.shieldai.com/api/v1';
export class NotificationService {
private static instance: NotificationService;
private userId: string | null = null;
private constructor() {}
static getInstance(): NotificationService {
if (!NotificationService.instance) {
NotificationService.instance = new NotificationService();
}
return NotificationService.instance;
}
setUserId(userId: string) {
this.userId = userId;
}
async requestPermission(): Promise<boolean> {
try {
const authStatus = await messaging().requestPermission();
const enabled =
authStatus === messaging.AuthorizationStatus.AUTHORIZED ||
authStatus === messaging.AuthorizationStatus.PROVISIONAL;
if (enabled) {
console.log('Push notification permission granted');
await this.registerDevice();
return true;
}
return false;
} catch (error) {
console.error('Failed to request permission:', error);
return false;
}
}
async registerDevice(): Promise<void> {
if (!this.userId) {
console.warn('User ID not set, cannot register device');
return;
}
try {
const token = await messaging().getToken();
const platform = Platform.OS === 'ios' ? 'ios' : 'android';
const response = await axios.post(`${API_BASE_URL}/devices/register`, {
userId: this.userId,
platform,
token,
deviceType: 'mobile',
appName: 'ShieldAI Mobile',
appVersion: '1.0.0',
});
console.log('Device registered:', response.data);
} catch (error) {
console.error('Failed to register device:', error);
}
}
setupListeners() {
// Foreground messages
messaging().onMessage(async (remoteMessage) => {
console.log('Foreground message received:', remoteMessage);
// Show local notification
this.showLocalNotification(remoteMessage);
});
// Background messages
messaging().setBackgroundMessageHandler(async (remoteMessage) => {
console.log('Background message received:', remoteMessage);
});
// Notification opened
messaging().onNotificationOpenedApp((remoteMessage) => {
console.log('Notification opened app:', remoteMessage);
// Navigate to relevant screen
this.handleNotificationTap(remoteMessage);
});
// Check if app was opened from notification
messaging()
.getInitialNotification()
.then((remoteMessage) => {
if (remoteMessage) {
console.log('App opened from notification:', remoteMessage);
this.handleNotificationTap(remoteMessage);
}
});
}
private showLocalNotification(message: any) {
// Use react-native-push-notification or similar
console.log('Show notification:', message.notification?.title);
}
private handleNotificationTap(message: any) {
// Navigate to relevant screen based on notification data
const { alertId, type } = message.data || {};
if (alertId) {
// Navigate to alert detail
console.log('Navigate to alert:', alertId);
}
}
async deregisterDevice(): Promise<void> {
if (!this.userId) return;
try {
const token = await messaging().getToken();
await axios.post(`${API_BASE_URL}/devices/deregister`, {
token,
userId: this.userId,
});
} catch (error) {
console.error('Failed to deregister device:', error);
}
}
}
```
### 4.2 Initialize in App Component
```typescript
// App.tsx
import React, { useEffect } from 'react';
import { NotificationService } from './src/services/NotificationService';
const notificationService = NotificationService.getInstance();
function App() {
useEffect(() => {
// Initialize push notifications
notificationService.setupListeners();
}, []);
// When user logs in
const handleLogin = async (userId: string) => {
notificationService.setUserId(userId);
await notificationService.requestPermission();
};
// When user logs out
const handleLogout = async () => {
await notificationService.deregisterDevice();
notificationService.setUserId('');
};
return (
// Your app components
);
}
```
## Step 5: Testing
### Android Emulator
1. Start Android emulator with Google Play Services
2. Install and run app
3. Trigger a test notification from backend
4. Check notification appears
### iOS Device (Required)
iOS Simulator does not support push notifications. Use a real device.
### Backend Test
```bash
# Test notification API
curl -X POST https://api.shieldai.com/api/v1/notifications/send \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"userId": "user-uuid",
"channel": "push",
"subject": "Test Alert",
"body": "This is a test notification from ShieldAI",
"priority": "high",
"metadata": {
"alertId": "alert-uuid",
"type": "darkweb_exposure"
}
}'
```
## Troubleshooting
### Android Issues
**Problem**: Token not received
- Check `google-services.json` is in correct location
- Verify Firebase project ID matches
- Check internet connection on emulator
**Problem**: Notifications not showing
- Ensure notification permissions granted
- Check battery optimization settings
- Verify notification channel is created
### iOS Issues
**Problem**: Token not received
- Verify Push Notifications capability is enabled
- Check APNs key configuration in backend
- Ensure device is not in development mode if using production certs
**Problem**: Notifications not showing
- Check notification permissions granted
- Verify APNs configuration on backend
- Ensure proper certificate/key is used
## Next Steps
1. ✅ Set up Firebase project
2. ✅ Configure APNs on Apple Developer Portal
3. ✅ Implement notification handling in app
4. ✅ Test on Android device/emulator
5. ✅ Test on iOS device
6. ✅ Integrate with DarkWatch and SpamShield alerts
7. ✅ Add notification preferences UI
8. ✅ Implement deep linking from notifications
## Resources
- [Firebase Cloud Messaging Docs](https://firebase.google.com/docs/cloud-messaging)
- [React Native Firebase Messaging](https://rnfirebase.io/messaging/usage)
- [Apple Push Notification Service](https://developer.apple.com/documentation/usernotifications)

View File

@@ -0,0 +1,462 @@
# Push Notifications Setup Guide (FCM & APNs)
## Overview
This guide covers setting up Firebase Cloud Messaging (FCM) for Android and Apple Push Notification service (APNs) for iOS push notifications in the ShieldAI mobile app.
## Architecture
```
┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Mobile │────▶│ API Gateway │────▶│ Notification │
│ App │ │ /devices/* │ │ Service │
└─────────────┘ └──────────────────┘ └────────┬────────┘
┌──────────────────────────────┼──────────────────────┐
│ │ │
▼ ▼ ▼
┌────────────────┐ ┌─────────────────┐ ┌──────────────────┐
│ Firebase │ │ Apple Push │ │ Redis (Rate │
│ Cloud │ │ Notification │ │ Limiting) │
│ Messaging │ │ Service │ │ │
│ (Android) │ │ (iOS) │ │ │
└────────────────┘ └─────────────────┘ └──────────────────┘
```
## Environment Variables
Add the following to your `.env` file:
```bash
# Firebase Cloud Messaging (FCM)
FCM_PROJECT_ID=your-firebase-project-id
FCM_CLIENT_EMAIL=service-account@your-project.iam.gserviceaccount.com
FCM_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"
# Apple Push Notification (APNs)
APNS_KEY_ID=your-key-id
APNS_TEAM_ID=your-team-id
APNS_BUNDLE_ID=com.yourcompany.shieldai
APNS_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"
# Redis (for rate limiting)
REDIS_URL=redis://localhost:6379
# Rate Limits
PUSH_RATE_LIMIT=100
RATE_LIMIT_WINDOW_SECONDS=60
```
## Firebase Cloud Messaging (FCM) Setup
### 1. Create Firebase Project
1. Go to [Firebase Console](https://console.firebase.google.com/)
2. Click **Add project**
3. Enter project name: `shieldai-production` (or your preferred name)
4. Disable Google Analytics (optional)
5. Click **Create project**
### 2. Enable Cloud Messaging
1. In Firebase Console, go to **Project settings**
2. Scroll down to **Your apps** section
3. Click the **Web** icon `</>` to add a web app
4. Register app nickname: `ShieldAI API`
5. **Do not** check "Also set up for Firebase Hosting"
6. Click **Register app**
7. Copy the `firebaseConfig` for later use in mobile app
### 3. Generate Service Account Key
1. Go to **Project settings****Service accounts**
2. Click **Generate new private key**
3. Save the downloaded JSON file securely
4. Extract the following values:
- `project_id`
- `client_email`
- `private_key`
### 4. Configure FCM in ShieldAI
Create a file `packages/shared-notifications/.fcm-config.json` (gitignored):
```json
{
"projectId": "your-firebase-project-id",
"clientEmail": "service-account@your-project.iam.gserviceaccount.com",
"privateKey": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"
}
```
Set environment variables:
```bash
export FCM_PROJECT_ID="your-firebase-project-id"
export FCM_CLIENT_EMAIL="service-account@your-project.iam.gserviceaccount.com"
export FCM_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"
```
### 5. Install Firebase Admin SDK
```bash
cd packages/shared-notifications
npm install firebase-admin
```
## Apple Push Notification (APNs) Setup
### 1. Create App ID
1. Go to [Apple Developer Portal](https://developer.apple.com/account/resources/identifiers/list)
2. Click **+** to create new identifier
3. Select **App IDs****App**
4. Enter description: `ShieldAI Mobile`
5. Enter Bundle ID: `com.yourcompany.shieldai`
6. Enable **Push Notifications** capability
7. Click **Continue****Register**
### 2. Create APNs Key
1. Go to **Certificates, IDs & Profiles****Keys**
2. Click **+** to create new key
3. Enter key name: `ShieldAI APNs Key`
4. Enable **Apple Push Notification service (APNs)**
5. Click **Continue****Register**
6. **Download the key** (`.p8` file) - you can only download once!
7. Note the **Key ID** displayed
### 3. Configure APNs in ShieldAI
Convert the `.p8` file to PEM format:
```bash
# Convert .p8 to PEM
openssl pkcs8 -topk8 -nocrypt -in AuthKey_XXXXXX.p8 -out apns_key.pem
# Copy the PEM content
cat apns_key.pem
```
Set environment variables:
```bash
export APNS_KEY_ID="XXXXXX"
export APNS_TEAM_ID="YYYYYY"
export APNS_BUNDLE_ID="com.yourcompany.shieldai"
export APNS_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"
```
### 4. Configure in Xcode
In your iOS app's `Info.plist`:
```xml
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>remote-notification</string>
</array>
```
Enable capabilities in Xcode:
- **Push Notifications**
- **Background Modes** → **Remote notifications**
## API Endpoints
### Device Registration
#### Register Device
```http
POST /api/v1/devices/register
Authorization: Bearer <JWT_TOKEN>
Content-Type: application/json
{
"platform": "android",
"fcmToken": "eXwR9...",
"appVersion": "1.0.0",
"osVersion": "Android 13"
}
```
Response:
```json
{
"success": true,
"device": {
"deviceId": "dev_1234567890_abc123",
"platform": "android",
"registeredAt": "2026-05-14T13:00:00.000Z"
},
"message": "Device registered successfully"
}
```
#### Update Device Tokens
```http
PUT /api/v1/devices/:deviceId/tokens
Authorization: Bearer <JWT_TOKEN>
Content-Type: application/json
{
"fcmToken": "new-token-here",
"apnsToken": "new-token-here"
}
```
#### Get Registered Devices
```http
GET /api/v1/devices
Authorization: Bearer <JWT_TOKEN>
```
#### Deregister Device
```http
DELETE /api/v1/devices/:deviceId
Authorization: Bearer <JWT_TOKEN>
```
## Mobile App Integration
### Android (React Native)
```javascript
import messaging from '@react-native-firebase/messaging';
import { API } from '@shieldai/api-client';
// Request permission
async function requestPushPermission() {
const authStatus = await messaging().requestPermission();
const enabled =
authStatus === messaging.AuthorizationStatus.AUTHORIZED ||
authStatus === messaging.AuthorizationStatus.PROVISIONAL;
if (enabled) {
console.log('Push permission granted:', authStatus);
}
}
// Get FCM token
async function getFCMToken() {
const token = await messaging().getToken();
return token;
}
// Register device with API
async function registerDevice() {
try {
const fcmToken = await getFCMToken();
const response = await API.post('/devices/register', {
platform: 'android',
fcmToken,
appVersion: '1.0.0',
osVersion: Platform.OS + ' ' + Platform.Version,
});
console.log('Device registered:', response.data);
} catch (error) {
console.error('Failed to register device:', error);
}
}
// Listen for token refresh
messaging().onTokenRefresh(async (newToken) => {
await API.put('/devices/tokens', {
fcmToken: newToken,
});
});
// Handle foreground messages
messaging().onMessage(async (remoteMessage) => {
console.log('Foreground message received:', remoteMessage);
// Show local notification
});
// Handle background messages
messaging().setBackgroundMessageHandler(async remoteMessage => {
console.log('Background message received:', remoteMessage);
});
```
### iOS (React Native)
```javascript
import messaging from '@react-native-firebase/messaging';
import { API } from '@shieldai/api-client';
// Request permission
async function requestPushPermission() {
const authStatus = await messaging().requestPermission();
const enabled =
authStatus === messaging.AuthorizationStatus.AUTHORIZED ||
authStatus === messaging.AuthorizationStatus.PROVISIONAL;
if (enabled) {
console.log('Push permission granted:', authStatus);
}
}
// Get APNs token
async function getAPNSToken() {
const token = await messaging().getToken();
return token;
}
// Register device with API
async function registerDevice() {
try {
const apnsToken = await getAPNSToken();
const response = await API.post('/devices/register', {
platform: 'ios',
apnsToken,
appVersion: '1.0.0',
osVersion: Platform.OS + ' ' + Platform.Version,
});
console.log('Device registered:', response.data);
} catch (error) {
console.error('Failed to register device:', error);
}
}
// Handle foreground messages
messaging().onMessage(async (remoteMessage => {
console.log('Foreground message received:', remoteMessage);
// Show local notification
}));
```
## Testing Push Notifications
### Test with Firebase Console
1. Go to [Firebase Console](https://console.firebase.google.com/) → **Cloud Messaging**
2. Click **Send your first message**
3. Enter notification title and body
4. Choose test device or all users
5. Click **Send test message**
### Test with API
```bash
# Send test push notification
curl -X POST https://your-api.com/api/v1/notifications/send \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"userId": "user-123",
"channel": "push",
"title": "Test Notification",
"body": "This is a test push notification",
"data": {
"type": "test",
"action": "open_app"
}
}'
```
### Test with cURL (Direct FCM)
```bash
curl -X POST https://fcm.googleapis.com/v1/projects/your-project-id/messages:send \
-H "Authorization: Bearer $(gcloud auth application-default print-access-token)" \
-H "Content-Type: application/json" \
-d '{
"message": {
"token": "FCM_TOKEN_HERE",
"notification": {
"title": "Test Title",
"body": "Test Body"
},
"data": {
"type": "test"
}
}
}'
```
## Production Checklist
### FCM Configuration
- [ ] Firebase project created
- [ ] Service account key generated and stored securely
- [ ] FCM environment variables configured
- [ ] Firebase Admin SDK initialized
- [ ] Test notifications sent successfully
### APNs Configuration
- [ ] App ID created with push capability
- [ ] APNs key generated and downloaded
- [ ] Key converted to PEM format
- [ ] APNs environment variables configured
- [ ] Xcode capabilities enabled
- [ ] Test notifications sent from simulator
### API Configuration
- [ ] Device registration endpoints working
- [ ] Token update endpoints working
- [ ] Rate limiting configured
- [ ] Error handling implemented
- [ ] Logging for push notifications
### Mobile App Configuration
- [ ] Push permissions requested
- [ ] Token retrieval implemented
- [ ] Device registration on app start
- [ ] Token refresh handling
- [ ] Foreground message handling
- [ ] Background message handling
- [ ] Notification display implemented
## Troubleshooting
### FCM Token Not Received
- Check Firebase configuration in mobile app
- Verify Google Services (Android) / GoogleService-Info.plist (iOS)
- Ensure push permissions granted
### APNs Token Not Received
- Verify App ID configuration in Apple Developer
- Check APNs key configuration
- Ensure background modes enabled in Xcode
- Test on actual device (not simulator)
### Notifications Not Delivering
- Check device registration status
- Verify tokens are valid
- Check rate limiting status
- Review server logs for errors
### Token Refresh Issues
- Listen for `onTokenRefresh` events
- Update tokens via API immediately
- Handle network errors gracefully
## Security Considerations
1. **Never expose service account keys** in client-side code
2. **Always validate** device ownership on server
3. **Use HTTPS** for all API calls
4. **Implement rate limiting** to prevent abuse
5. **Store tokens securely** using secure storage libraries
6. **Deregister devices** on user logout
## Monitoring
Monitor the following metrics:
- Push notification delivery rate
- Token refresh frequency
- Device registration failures
- Rate limit hits
- Notification open rate
## Support
- Firebase Support: https://firebase.google.com/support
- Apple Developer Support: https://developer.apple.com/contact/
- FCM Documentation: https://firebase.google.com/docs/cloud-messaging
- APNs Documentation: https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server

353
docs/STRIPE_INTEGRATION.md Normal file
View File

@@ -0,0 +1,353 @@
# Stripe Integration Guide
## Overview
This guide covers the Stripe live API integration for ShieldAI subscription management. The implementation includes webhook handlers, subscription management endpoints, and tier-based feature gating.
## Environment Variables
Add the following to your `.env` file:
```bash
# Stripe Configuration
STRIPE_API_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_FREE_TIER_PRICE_ID=price_...
STRIPE_BASIC_TIER_PRICE_ID=price_...
STRIPE_PLUS_TIER_PRICE_ID=price_...
STRIPE_PREMIUM_TIER_PRICE_ID=price_...
```
## Getting Stripe Live API Keys
### 1. Get Live API Key
1. Go to [Stripe Dashboard](https://dashboard.stripe.com/)
2. Navigate to **Developers****API keys**
3. Toggle **Live mode** (top right corner)
4. Copy the **Secret key** (starts with `sk_live_`)
### 2. Get Webhook Signing Secret
1. In Stripe Dashboard, go to **Developers****Webhooks**
2. Click **Add endpoint**
3. Add your webhook URL: `https://your-api-domain.com/api/v1/billing/webhooks/stripe`
4. Select these events to listen for:
- `customer.subscription.created`
- `customer.subscription.updated`
- `customer.subscription.deleted`
- `invoice.payment_succeeded`
- `invoice.payment_failed`
5. Click **Add endpoint**
6. Copy the **Signing secret** (starts with `whsec_`)
### 3. Create Price IDs for Subscription Tiers
1. Go to **Products** in Stripe Dashboard
2. Create products for each tier:
- Free Tier ( $0/month)
- Basic Tier ($9.99/month)
- Plus Tier ($19.99/month)
- Premium Tier ($49.99/month)
3. For each product, create a recurring price
4. Copy the Price IDs (start with `price_`)
## API Endpoints
### Subscription Management
#### Get Current Subscription
```http
GET /api/v1/billing/subscription
Authorization: Bearer <JWT_TOKEN>
```
Response:
```json
{
"subscription": {
"id": "sub_123",
"status": "active",
"currentPeriodStart": "2026-05-01T00:00:00.000Z",
"currentPeriodEnd": "2026-06-01T00:00:00.000Z",
"cancelAtPeriodEnd": false
},
"customer": {
"id": "cus_123"
}
}
```
#### Create Subscription
```http
POST /api/v1/billing/subscription/create
Authorization: Bearer <JWT_TOKEN>
Content-Type: application/json
{
"tier": "basic",
"customerId": "cus_123"
}
```
#### Update Subscription Tier
```http
PUT /api/v1/billing/subscription/:subscriptionId/tier
Authorization: Bearer <JWT_TOKEN>
Content-Type: application/json
{
"tier": "plus"
}
```
#### Cancel Subscription
```http
DELETE /api/v1/billing/subscription/:subscriptionId
Authorization: Bearer <JWT_TOKEN>
Content-Type: application/json
{
"cancelAtPeriodEnd": true
}
```
#### Get User Tier
```http
GET /api/v1/billing/user/tier
Authorization: Bearer <JWT_TOKEN>
```
Response:
```json
{
"tier": "basic",
"limits": {
"callMinutesLimit": 500,
"smsCountLimit": 2000,
"darkWebScans": 12
}
}
```
#### Create Customer Portal Session
```http
POST /api/v1/billing/customer/portal
Authorization: Bearer <JWT_TOKEN>
Content-Type: application/json
{
"customerId": "cus_123",
"returnUrl": "https://yourapp.com/billing"
}
```
Response:
```json
{
"url": "https://billing.stripe.com/p/login/...",
"expiresAt": "2026-05-14T14:00:00.000Z"
}
```
#### Get Invoice History
```http
GET /api/v1/billing/invoices?customerId=cus_123
Authorization: Bearer <JWT_TOKEN>
```
### Webhook Handler
#### Stripe Webhook
```http
POST /api/v1/billing/webhooks/stripe
Stripe-Signature: <SIGNATURE>
Content-Type: application/json
<Raw Stripe Event Body>
```
## Webhook Events Handled
### customer.subscription.created
Triggered when a new subscription is created.
### customer.subscription.updated
Triggered when subscription details change (tier upgrade/downgrade, payment method update).
### customer.subscription.deleted
Triggered when a subscription is cancelled.
### invoice.payment_succeeded
Triggered when a payment is successfully processed.
### invoice.payment_failed
Triggered when a payment fails.
## Testing
### Test with Stripe CLI
1. Install [Stripe CLI](https://stripe.com/docs/stripe-cli)
2. Login: `stripe login`
3. Forward webhooks: `stripe listen --forward-to localhost:3000/api/v1/billing/webhooks/stripe`
### Test Events
```bash
# Trigger a subscription created event
stripe trigger customer.subscription.created
# Trigger a payment succeeded event
stripe trigger invoice.payment_succeeded
```
## Mobile App Integration
### React Native Example
```javascript
import { API } from '@shieldai/api-client';
// Get current subscription
const getSubscription = async () => {
try {
const response = await API.get('/billing/subscription');
return response.data;
} catch (error) {
console.error('Failed to fetch subscription:', error);
}
};
// Create subscription
const createSubscription = async (tier, customerId) => {
try {
const response = await API.post('/billing/subscription/create', {
tier,
customerId,
});
return response.data;
} catch (error) {
console.error('Failed to create subscription:', error);
}
};
// Upgrade subscription
const upgradeSubscription = async (subscriptionId, newTier) => {
try {
const response = await API.put(
`/billing/subscription/${subscriptionId}/tier`,
{ tier: newTier }
);
return response.data;
} catch (error) {
console.error('Failed to upgrade subscription:', error);
}
};
// Cancel subscription
const cancelSubscription = async (subscriptionId) => {
try {
const response = await API.delete(
`/billing/subscription/${subscriptionId}`,
{ data: { cancelAtPeriodEnd: true } }
);
return response.data;
} catch (error) {
console.error('Failed to cancel subscription:', error);
}
};
```
## Feature Gating
Use the middleware to protect routes based on subscription tier:
```typescript
import { requireTier } from '@shieldai/shared-billing';
import { SubscriptionTier } from '@shieldai/shared-billing';
// Require minimum tier
fastify.get(
'/premium-feature',
{
preHandler: requireTier([SubscriptionTier.BASIC, SubscriptionTier.PLUS, SubscriptionTier.PREMIUM])
},
async (request, reply) => {
// Only accessible to BASIC tier and above
}
);
// Require specific tier
fastify.get(
'/exclusive-feature',
{
preHandler: requireTier([SubscriptionTier.PREMIUM])
},
async (request, reply) => {
// Only accessible to PREMIUM tier
}
);
```
## Deployment Checklist
- [ ] Set `STRIPE_API_KEY` to live key (not test key)
- [ ] Set `STRIPE_WEBHOOK_SECRET` to live webhook secret
- [ ] Configure webhook endpoint in Stripe Dashboard
- [ ] Verify webhook events are being received
- [ ] Test subscription creation flow
- [ ] Test tier upgrade/downgrade flow
- [ ] Test cancellation flow
- [ ] Verify feature gating works correctly
- [ ] Monitor Stripe dashboard for errors
- [ ] Set up alerts for failed payments
## Production Considerations
### Security
1. **Never expose secret keys** in client-side code
2. **Always verify webhook signatures** on the server
3. **Use HTTPS** for all API endpoints in production
4. **Implement rate limiting** on webhook endpoints
### Error Handling
1. **Idempotency**: Webhook events may be delivered multiple times
2. **Retry logic**: Stripe will retry failed webhook deliveries
3. **Logging**: Log all webhook events for debugging
4. **Alerts**: Set up alerts for payment failures
### Compliance
1. **PCI DSS**: Use Stripe Elements for payment collection
2. **GDPR**: Handle customer data according to regulations
3. **Tax**: Consider tax calculation for different regions
## Troubleshooting
### Webhook Signature Verification Fails
- Ensure `STRIPE_WEBHOOK_SECRET` is correctly set
- Verify the webhook URL matches what's configured in Stripe
- Check that raw body is being captured (not parsed JSON)
### Subscription Creation Fails
- Verify `STRIPE_API_KEY` is valid
- Check that price IDs exist and are active
- Ensure customer ID is valid
### Tier Not Updating
- Verify the new tier's price ID exists
- Check for active subscriptions on the customer
- Review Stripe dashboard for error messages
## Support
For issues or questions:
- Stripe Dashboard: https://dashboard.stripe.com/
- Stripe Docs: https://stripe.com/docs
- Stripe Support: https://support.stripe.com/

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[] = [

11772
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,7 @@ import {
SchedulerConfig,
ScheduledScanResult,
} from './types';
// @ts-expect-error uuid v10 ships with its own types but module resolution is complex
import { v4 as uuidv4 } from 'uuid';
const DEFAULT_SCHEDULER_CONFIG: SchedulerConfig = {
@@ -72,6 +73,7 @@ export class HomeTitleSchedulerService {
const scanId = uuidv4();
const startedAt = new Date().toISOString();
const errors: string[] = [];
let propertiesScanned = 0;
let changesDetected = 0;
let alertsCreated = 0;
let notificationsSent = 0;
@@ -95,6 +97,7 @@ export class HomeTitleSchedulerService {
const propertySnapshots = await this.fetchLatestSnapshots(
subscription.userId,
);
propertiesScanned += propertySnapshots.length;
for (const snapshot of propertySnapshots) {
const previousSnapshot = await this.fetchPreviousSnapshot(
@@ -107,7 +110,7 @@ export class HomeTitleSchedulerService {
const result = detectChanges(previousSnapshot, snapshot);
if (shouldTriggerAlert(result, 'moderate')) {
if (shouldTriggerAlert(result, 'warning')) {
changesDetected++;
const alert = await homeTitleAlertPipeline.processChangeDetection(
@@ -140,7 +143,7 @@ export class HomeTitleSchedulerService {
const scanResult: ScheduledScanResult = {
scanId,
propertiesScanned: changesDetected,
propertiesScanned,
changesDetected,
alertsCreated,
notificationsSent,