more package declarations
This commit is contained in:
389
docs/MOBILE_PUSH_INTEGRATION.md
Normal file
389
docs/MOBILE_PUSH_INTEGRATION.md
Normal 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)
|
||||||
462
docs/PUSH_NOTIFICATIONS_SETUP.md
Normal file
462
docs/PUSH_NOTIFICATIONS_SETUP.md
Normal 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
353
docs/STRIPE_INTEGRATION.md
Normal 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/
|
||||||
@@ -1,4 +1,11 @@
|
|||||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
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 {
|
export interface AuthRequest extends FastifyRequest {
|
||||||
user?: {
|
user?: {
|
||||||
@@ -27,17 +34,35 @@ export async function authMiddleware(fastify: FastifyInstance) {
|
|||||||
if (authHeader?.startsWith('Bearer ')) {
|
if (authHeader?.startsWith('Bearer ')) {
|
||||||
const token = authHeader.slice(7);
|
const token = authHeader.slice(7);
|
||||||
try {
|
try {
|
||||||
// In production, decode and verify JWT
|
if (!JWT_SECRET) {
|
||||||
// For now, we'll attach a placeholder user
|
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 = {
|
authReq.user = {
|
||||||
id: 'user-placeholder',
|
id: decoded.id,
|
||||||
email: 'user@example.com',
|
email: decoded.email,
|
||||||
role: 'user',
|
role: decoded.role,
|
||||||
|
organizationId: decoded.organizationId,
|
||||||
};
|
};
|
||||||
authReq.authType = 'jwt';
|
authReq.authType = 'jwt';
|
||||||
return;
|
return;
|
||||||
} catch (err) {
|
} 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' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,39 +7,97 @@ function getUserId(request: FastifyRequest): string | undefined {
|
|||||||
return (request.user as AuthUser | undefined)?.id;
|
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) {
|
export function correlationRoutes(fastify: FastifyInstance) {
|
||||||
fastify.get("/dashboard", async (request, reply) => {
|
fastify.get(
|
||||||
const userId = getUserId(request);
|
"/dashboard",
|
||||||
if (!userId || userId === "anonymous") {
|
{
|
||||||
return reply.code(401).send({ error: "User not authenticated" });
|
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 =
|
fastify.get(
|
||||||
parseInt(
|
"/groups",
|
||||||
(request.query as Record<string, string>).timeWindow as string
|
{
|
||||||
) || 60;
|
schema: {
|
||||||
const data = await correlationService.getDashboardData(userId, timeWindow);
|
...paginatedQuerySchema,
|
||||||
return reply.send(data);
|
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 query = request.query as Record<string, string | number>;
|
||||||
const userId = getUserId(request);
|
const result = await correlationService.getCorrelationGroups({
|
||||||
if (!userId || userId === "anonymous") {
|
userId,
|
||||||
return reply.code(401).send({ error: "User not authenticated" });
|
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(
|
fastify.get(
|
||||||
"/groups/:groupId",
|
"/groups/:groupId",
|
||||||
@@ -114,26 +172,47 @@ export function correlationRoutes(fastify: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
fastify.get("/alerts", async (request, reply) => {
|
fastify.get(
|
||||||
const userId = getUserId(request);
|
"/alerts",
|
||||||
if (!userId || userId === "anonymous") {
|
{
|
||||||
return reply.code(401).send({ error: "User not authenticated" });
|
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 query = request.query as Record<string, string | number>;
|
||||||
const result = await correlationService.getCorrelatedAlerts({
|
const result = await correlationService.getCorrelatedAlerts({
|
||||||
userId,
|
userId,
|
||||||
source: (query.source as any) || undefined,
|
source: (query.source as any) || undefined,
|
||||||
category: (query.category as any) || undefined,
|
category: (query.category as any) || undefined,
|
||||||
severity: (query.severity as any) || undefined,
|
severity: (query.severity as any) || undefined,
|
||||||
timeWindowMinutes: query.timeWindow
|
timeWindowMinutes:
|
||||||
? parseInt(query.timeWindow)
|
typeof query.timeWindow === "number" ? query.timeWindow : 60,
|
||||||
: 60,
|
limit: typeof query.limit === "number" ? query.limit : 50,
|
||||||
limit: query.limit ? parseInt(query.limit) : 50,
|
offset: typeof query.offset === "number" ? query.offset : 0,
|
||||||
offset: query.offset ? parseInt(query.offset) : 0,
|
});
|
||||||
});
|
return reply.send(result);
|
||||||
return reply.send(result);
|
}
|
||||||
});
|
);
|
||||||
|
|
||||||
fastify.post(
|
fastify.post(
|
||||||
"/ingest/darkwatch",
|
"/ingest/darkwatch",
|
||||||
|
|||||||
@@ -169,4 +169,9 @@ export async function reportRoutes(fastify: FastifyInstance) {
|
|||||||
const createdIds = await reportService.scheduleAnnualReports();
|
const createdIds = await reportService.scheduleAnnualReports();
|
||||||
return reply.code(200).send({ scheduled: createdIds.length, reportIds: createdIds });
|
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 });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Fastify from "fastify";
|
|||||||
import cors from "@fastify/cors";
|
import cors from "@fastify/cors";
|
||||||
import helmet from "@fastify/helmet";
|
import helmet from "@fastify/helmet";
|
||||||
import sensible from "@fastify/sensible";
|
import sensible from "@fastify/sensible";
|
||||||
|
import rawBody from "fastify-raw-body";
|
||||||
import { extractOrGenerateRequestId } from "@shieldai/types";
|
import { extractOrGenerateRequestId } from "@shieldai/types";
|
||||||
import { authMiddleware } from "./middleware/auth.middleware";
|
import { authMiddleware } from "./middleware/auth.middleware";
|
||||||
import { errorHandlingMiddleware } from "./middleware/error-handling.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 { waitlistRoutes } from "./routes/waitlist.routes";
|
||||||
import { blogRoutes } from "./routes/blog.routes";
|
import { blogRoutes } from "./routes/blog.routes";
|
||||||
import { blogAdminRoutes } from "./routes/blog-admin.routes";
|
import { blogAdminRoutes } from "./routes/blog-admin.routes";
|
||||||
|
import { routes } from "./routes";
|
||||||
import { captureSentryError } from "@shieldai/monitoring";
|
import { captureSentryError } from "@shieldai/monitoring";
|
||||||
import { getCorsOrigins } from "./config/api.config";
|
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({
|
const app = Fastify({
|
||||||
logger: {
|
logger: {
|
||||||
@@ -30,6 +36,7 @@ async function bootstrap() {
|
|||||||
await app.register(cors, { origin: corsOrigins });
|
await app.register(cors, { origin: corsOrigins });
|
||||||
await app.register(helmet);
|
await app.register(helmet);
|
||||||
await app.register(sensible);
|
await app.register(sensible);
|
||||||
|
await app.register(rawBody, { runFirst: true });
|
||||||
|
|
||||||
// Register auth middleware to populate request.user
|
// Register auth middleware to populate request.user
|
||||||
await app.register(authMiddleware);
|
await app.register(authMiddleware);
|
||||||
@@ -52,16 +59,54 @@ async function bootstrap() {
|
|||||||
request.headers["x-request-id"] = requestId;
|
request.headers["x-request-id"] = requestId;
|
||||||
});
|
});
|
||||||
|
|
||||||
await app.register(darkwatchRoutes);
|
await app.register(routes);
|
||||||
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);
|
|
||||||
|
|
||||||
app.get("/health", async () => ({ status: "ok", timestamp: new Date().toISOString() }));
|
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 {
|
try {
|
||||||
await app.listen({ port: parseInt(process.env.PORT || "3000", 10), host: "0.0.0.0" });
|
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}`);
|
app.log.info(`Server listening on port ${process.env.PORT || 3000}`);
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -2,7 +2,9 @@
|
|||||||
"extends": "../../tsconfig.json",
|
"extends": "../../tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"rootDir": "./src"
|
"rootDir": "./src",
|
||||||
|
"module": "ES2022",
|
||||||
|
"moduleResolution": "Bundler"
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"]
|
"include": ["src/**/*.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,16 +75,42 @@ async function processReportGeneration(
|
|||||||
|
|
||||||
const userName = user?.name || notifyEmail.split('@')[0];
|
const userName = user?.name || notifyEmail.split('@')[0];
|
||||||
|
|
||||||
await emailService.sendWithTemplate(notifyEmail, {
|
const templateId = report.reportType === 'WEEKLY_DIGEST' ? 'weekly_digest' : 'report_ready';
|
||||||
templateId: 'report_ready',
|
|
||||||
variables: {
|
if (report.reportType === 'WEEKLY_DIGEST' && report.dataPayload) {
|
||||||
name: userName,
|
const payload = typeof report.dataPayload === 'string' ? JSON.parse(report.dataPayload) : report.dataPayload;
|
||||||
report_title: report.title,
|
|
||||||
report_summary: report.summary || 'Your protection report contains detailed statistics and recommendations.',
|
await emailService.sendWithTemplate(notifyEmail, {
|
||||||
report_url: `${dashboardUrl}/reports/${report.id}`,
|
templateId: 'weekly_digest',
|
||||||
pdf_url: report.pdfUrl || `${dashboardUrl}/api/v1/reports/${report.id}/pdf`,
|
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({
|
await prisma.securityReport.update({
|
||||||
where: { id: report.id },
|
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() {
|
export async function scheduleAnnualReportTrigger() {
|
||||||
return reportSchedulerQueue.add('trigger-annual-reports', {}, {
|
return reportSchedulerQueue.add('trigger-annual-reports', {}, {
|
||||||
repeat: { pattern: '0 0 1 1 *' },
|
repeat: { pattern: '0 0 1 1 *' },
|
||||||
|
|||||||
237
packages/mobile-api-client/README.md
Normal file
237
packages/mobile-api-client/README.md
Normal 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
|
||||||
29
packages/mobile-api-client/package.json
Normal file
29
packages/mobile-api-client/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
249
packages/mobile-api-client/src/api/api-client.ts
Normal file
249
packages/mobile-api-client/src/api/api-client.ts
Normal 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;
|
||||||
|
};
|
||||||
46
packages/mobile-api-client/src/api/auth.service.ts
Normal file
46
packages/mobile-api-client/src/api/auth.service.ts
Normal 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();
|
||||||
47
packages/mobile-api-client/src/api/device.service.ts
Normal file
47
packages/mobile-api-client/src/api/device.service.ts
Normal 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();
|
||||||
53
packages/mobile-api-client/src/api/notification.service.ts
Normal file
53
packages/mobile-api-client/src/api/notification.service.ts
Normal 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();
|
||||||
53
packages/mobile-api-client/src/api/subscription.service.ts
Normal file
53
packages/mobile-api-client/src/api/subscription.service.ts
Normal 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();
|
||||||
53
packages/mobile-api-client/src/index.ts
Normal file
53
packages/mobile-api-client/src/index.ts
Normal 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';
|
||||||
21
packages/mobile-api-client/src/react-native.d.ts
vendored
Normal file
21
packages/mobile-api-client/src/react-native.d.ts
vendored
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
93
packages/mobile-api-client/src/storage/token-storage.ts
Normal file
93
packages/mobile-api-client/src/storage/token-storage.ts
Normal 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),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
};
|
||||||
46
packages/mobile-api-client/src/types/auth.types.ts
Normal file
46
packages/mobile-api-client/src/types/auth.types.ts
Normal 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;
|
||||||
|
}
|
||||||
37
packages/mobile-api-client/src/types/common.types.ts
Normal file
37
packages/mobile-api-client/src/types/common.types.ts
Normal 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;
|
||||||
|
}
|
||||||
30
packages/mobile-api-client/src/types/device.types.ts
Normal file
30
packages/mobile-api-client/src/types/device.types.ts
Normal 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;
|
||||||
|
}
|
||||||
5
packages/mobile-api-client/src/types/index.ts
Normal file
5
packages/mobile-api-client/src/types/index.ts
Normal 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';
|
||||||
27
packages/mobile-api-client/src/types/notification.types.ts
Normal file
27
packages/mobile-api-client/src/types/notification.types.ts
Normal 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>;
|
||||||
|
}
|
||||||
49
packages/mobile-api-client/src/types/subscription.types.ts
Normal file
49
packages/mobile-api-client/src/types/subscription.types.ts
Normal 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 }>;
|
||||||
|
};
|
||||||
|
}
|
||||||
141
packages/mobile-api-client/src/utils/request-queue.ts
Normal file
141
packages/mobile-api-client/src/utils/request-queue.ts
Normal 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();
|
||||||
31
packages/mobile-api-client/tsconfig.json
Normal file
31
packages/mobile-api-client/tsconfig.json
Normal 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
@@ -404,5 +404,26 @@ describe('data-collector', () => {
|
|||||||
|
|
||||||
expect(result.homeTitleStats).toBeUndefined();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -294,7 +294,7 @@ export async function collectAllReportData(
|
|||||||
protectionScore,
|
protectionScore,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (reportType === 'ANNUAL_PREMIUM') {
|
if (reportType === 'ANNUAL_PREMIUM' || reportType === 'WEEKLY_DIGEST') {
|
||||||
payload.homeTitleStats = await collectHomeTitleStats(
|
payload.homeTitleStats = await collectHomeTitleStats(
|
||||||
subscriptionId,
|
subscriptionId,
|
||||||
periodStart,
|
periodStart,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -167,6 +167,55 @@ export class ReportService {
|
|||||||
return createdIds;
|
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[]> {
|
async scheduleAnnualReports(): Promise<string[]> {
|
||||||
const premiumSubscriptions = await prisma.subscription.findMany({
|
const premiumSubscriptions = await prisma.subscription.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -225,6 +274,11 @@ export class ReportService {
|
|||||||
if (reportType === 'MONTHLY_PLUS') {
|
if (reportType === 'MONTHLY_PLUS') {
|
||||||
return new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
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());
|
return new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,6 +293,9 @@ export class ReportService {
|
|||||||
year: 'numeric',
|
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()}`;
|
return `Annual Protection Audit — ${periodStart.getFullYear()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
3
packages/shared-db/prisma/migrations/migration_lock.toml
Normal file
3
packages/shared-db/prisma/migrations/migration_lock.toml
Normal 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"
|
||||||
@@ -21,22 +21,22 @@ model User {
|
|||||||
name String?
|
name String?
|
||||||
image String?
|
image String?
|
||||||
role UserRole @default(user)
|
role UserRole @default(user)
|
||||||
|
|
||||||
// Relationships
|
// Relationships
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
familyGroups FamilyGroupMember[]
|
familyGroups FamilyGroupMember[]
|
||||||
familyGroupOwned FamilyGroup[] @relation("FamilyGroupOwner")
|
familyGroupOwned FamilyGroup[] @relation("FamilyGroupOwner")
|
||||||
subscriptions Subscription[]
|
subscriptions Subscription[]
|
||||||
alerts Alert[]
|
alerts Alert[]
|
||||||
voiceEnrollments VoiceEnrollment[]
|
voiceEnrollments VoiceEnrollment[]
|
||||||
voiceAnalyses VoiceAnalysis[]
|
voiceAnalyses VoiceAnalysis[]
|
||||||
spamFeedback SpamFeedback[]
|
spamFeedback SpamFeedback[]
|
||||||
spamRules SpamRule[]
|
spamRules SpamRule[]
|
||||||
|
|
||||||
// Audit
|
// Audit
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([email])
|
@@index([email])
|
||||||
@@index([role])
|
@@index([role])
|
||||||
@@ -50,7 +50,7 @@ enum UserRole {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Account {
|
model Account {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String
|
userId String
|
||||||
provider String
|
provider String
|
||||||
providerAccountId String
|
providerAccountId String
|
||||||
@@ -59,11 +59,11 @@ model Account {
|
|||||||
expires_at Int?
|
expires_at Int?
|
||||||
token_type String?
|
token_type String?
|
||||||
scope String?
|
scope String?
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@unique([userId, provider, providerAccountId])
|
@@unique([userId, provider, providerAccountId])
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@ -74,11 +74,11 @@ model Session {
|
|||||||
userId String
|
userId String
|
||||||
sessionToken String @unique
|
sessionToken String @unique
|
||||||
expires DateTime
|
expires DateTime
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([sessionToken])
|
@@index([sessionToken])
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@ -89,30 +89,30 @@ model Session {
|
|||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
model FamilyGroup {
|
model FamilyGroup {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
name String
|
name String
|
||||||
ownerId String
|
ownerId String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
owner User @relation("FamilyGroupOwner", fields: [ownerId], references: [id])
|
owner User @relation("FamilyGroupOwner", fields: [ownerId], references: [id])
|
||||||
members FamilyGroupMember[]
|
members FamilyGroupMember[]
|
||||||
subscriptions Subscription[]
|
subscriptions Subscription[]
|
||||||
|
|
||||||
@@index([ownerId])
|
@@index([ownerId])
|
||||||
@@index([name])
|
@@index([name])
|
||||||
}
|
}
|
||||||
|
|
||||||
model FamilyGroupMember {
|
model FamilyGroupMember {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
groupId String
|
groupId String
|
||||||
userId String
|
userId String
|
||||||
role FamilyMemberRole @default(member)
|
role FamilyMemberRole @default(member)
|
||||||
joinedAt DateTime @default(now())
|
joinedAt DateTime @default(now())
|
||||||
|
|
||||||
group FamilyGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
group FamilyGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -128,25 +128,28 @@ enum FamilyMemberRole {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Subscription {
|
model Subscription {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String
|
userId String
|
||||||
familyGroupId String?
|
familyGroupId String?
|
||||||
stripeId String? @unique
|
stripeId String? @unique
|
||||||
tier SubscriptionTier @default(basic)
|
tier SubscriptionTier @default(basic)
|
||||||
status SubscriptionStatus @default(active)
|
status SubscriptionStatus @default(active)
|
||||||
currentPeriodStart DateTime
|
currentPeriodStart DateTime
|
||||||
currentPeriodEnd DateTime
|
currentPeriodEnd DateTime
|
||||||
cancelAtPeriodEnd Boolean @default(false)
|
cancelAtPeriodEnd Boolean @default(false)
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
familyGroup FamilyGroup? @relation(fields: [familyGroupId], references: [id])
|
familyGroup FamilyGroup? @relation(fields: [familyGroupId], references: [id])
|
||||||
|
|
||||||
watchlistItems WatchlistItem[]
|
watchlistItems WatchlistItem[]
|
||||||
exposures Exposure[]
|
exposures Exposure[]
|
||||||
alerts Alert[]
|
alerts Alert[]
|
||||||
|
propertyWatchlistItems PropertyWatchlistItem[]
|
||||||
createdAt DateTime @default(now())
|
propertySnapshots PropertySnapshot[]
|
||||||
updatedAt DateTime @updatedAt
|
propertyChanges PropertyChange[]
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([familyGroupId])
|
@@index([familyGroupId])
|
||||||
@@ -173,18 +176,18 @@ enum SubscriptionStatus {
|
|||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
model WatchlistItem {
|
model WatchlistItem {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
subscriptionId String
|
subscriptionId String
|
||||||
type WatchlistType
|
type WatchlistType
|
||||||
value String
|
value String
|
||||||
hash String // SHA-256 hash for deduplication
|
hash String // SHA-256 hash for deduplication
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
|
|
||||||
subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
|
subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
|
||||||
exposures Exposure[]
|
exposures Exposure[]
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@unique([subscriptionId, type, hash])
|
@@unique([subscriptionId, type, hash])
|
||||||
@@index([subscriptionId])
|
@@index([subscriptionId])
|
||||||
@@ -201,7 +204,7 @@ enum WatchlistType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Exposure {
|
model Exposure {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
subscriptionId String
|
subscriptionId String
|
||||||
watchlistItemId String?
|
watchlistItemId String?
|
||||||
source ExposureSource
|
source ExposureSource
|
||||||
@@ -209,16 +212,16 @@ model Exposure {
|
|||||||
identifier String
|
identifier String
|
||||||
identifierHash String
|
identifierHash String
|
||||||
severity ExposureSeverity @default(info)
|
severity ExposureSeverity @default(info)
|
||||||
metadata Json? // Additional source-specific data
|
metadata Json? // Additional source-specific data
|
||||||
isFirstTime Boolean @default(false)
|
isFirstTime Boolean @default(false)
|
||||||
|
|
||||||
subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
|
subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
|
||||||
watchlistItem WatchlistItem? @relation(fields: [watchlistItemId], references: [id])
|
watchlistItem WatchlistItem? @relation(fields: [watchlistItemId], references: [id])
|
||||||
alerts Alert[]
|
alerts Alert[]
|
||||||
|
|
||||||
detectedAt DateTime
|
detectedAt DateTime
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([subscriptionId])
|
@@index([subscriptionId])
|
||||||
@@index([watchlistItemId])
|
@@index([watchlistItemId])
|
||||||
@@ -228,7 +231,7 @@ model Exposure {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum ExposureSource {
|
enum ExposureSource {
|
||||||
hibp // Have I Been Pwned
|
hibp // Have I Been Pwned
|
||||||
securityTrails
|
securityTrails
|
||||||
censys
|
censys
|
||||||
darkWebForum
|
darkWebForum
|
||||||
@@ -247,24 +250,27 @@ enum ExposureSeverity {
|
|||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
model Alert {
|
model Alert {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
subscriptionId String
|
subscriptionId String
|
||||||
userId String
|
userId String
|
||||||
exposureId String?
|
exposureId String?
|
||||||
type AlertType
|
type AlertType
|
||||||
title String
|
title String
|
||||||
message String
|
message String
|
||||||
severity AlertSeverity @default(info)
|
severity AlertSeverity @default(info)
|
||||||
isRead Boolean @default(false)
|
isRead Boolean @default(false)
|
||||||
readAt DateTime?
|
readAt DateTime?
|
||||||
channel AlertChannel[] // Array of notification channels
|
channel AlertChannel[] // Array of notification channels
|
||||||
|
|
||||||
subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
|
subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
exposure Exposure? @relation(fields: [exposureId], references: [id])
|
exposure Exposure? @relation(fields: [exposureId], references: [id])
|
||||||
|
propertyChange PropertyChange? @relation("PropertyAlerts", fields: [propertyChangeId], references: [id])
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
propertyChangeId String?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([subscriptionId])
|
@@index([subscriptionId])
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@ -297,37 +303,37 @@ enum AlertChannel {
|
|||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
model VoiceEnrollment {
|
model VoiceEnrollment {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String
|
userId String
|
||||||
name String
|
name String
|
||||||
voiceHash String // FAISS embedding hash
|
voiceHash String // FAISS embedding hash
|
||||||
audioMetadata Json? // Sample rate, duration, etc.
|
audioMetadata Json? // Sample rate, duration, etc.
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
analyses VoiceAnalysis[]
|
analyses VoiceAnalysis[]
|
||||||
|
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([voiceHash])
|
@@index([voiceHash])
|
||||||
}
|
}
|
||||||
|
|
||||||
model VoiceAnalysis {
|
model VoiceAnalysis {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
enrollmentId String?
|
enrollmentId String?
|
||||||
userId String
|
userId String
|
||||||
audioHash String // Content hash of audio file
|
audioHash String // Content hash of audio file
|
||||||
isSynthetic Boolean
|
isSynthetic Boolean
|
||||||
confidence Float // 0.0 to 1.0
|
confidence Float // 0.0 to 1.0
|
||||||
analysisResult Json // Full ML analysis results
|
analysisResult Json // Full ML analysis results
|
||||||
audioUrl String // S3 storage URL
|
audioUrl String // S3 storage URL
|
||||||
|
|
||||||
enrollment VoiceEnrollment? @relation(fields: [enrollmentId], references: [id])
|
enrollment VoiceEnrollment? @relation(fields: [enrollmentId], references: [id])
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([enrollmentId])
|
@@index([enrollmentId])
|
||||||
@@ -339,19 +345,19 @@ model VoiceAnalysis {
|
|||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
model SpamFeedback {
|
model SpamFeedback {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String
|
userId String
|
||||||
phoneNumber String
|
phoneNumber String
|
||||||
phoneNumberHash String // SHA-256 hash
|
phoneNumberHash String // SHA-256 hash
|
||||||
isSpam Boolean
|
isSpam Boolean
|
||||||
confidence Float? // ML model confidence
|
confidence Float? // ML model confidence
|
||||||
feedbackType FeedbackType
|
feedbackType FeedbackType
|
||||||
metadata Json? // Call duration, time, etc.
|
metadata Json? // Call duration, time, etc.
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([phoneNumberHash])
|
@@index([phoneNumberHash])
|
||||||
@@ -366,19 +372,19 @@ enum FeedbackType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model SpamRule {
|
model SpamRule {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String?
|
userId String?
|
||||||
isGlobal Boolean @default(false)
|
isGlobal Boolean @default(false)
|
||||||
ruleType RuleType
|
ruleType RuleType
|
||||||
pattern String
|
pattern String
|
||||||
action RuleAction
|
action RuleAction
|
||||||
priority Int @default(0)
|
priority Int @default(0)
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
|
|
||||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([isGlobal])
|
@@index([isGlobal])
|
||||||
@@ -405,16 +411,16 @@ enum RuleAction {
|
|||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
model AuditLog {
|
model AuditLog {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String?
|
userId String?
|
||||||
action String
|
action String
|
||||||
resource String
|
resource String
|
||||||
resourceId String?
|
resourceId String?
|
||||||
changes Json? // Before/after values
|
changes Json? // Before/after values
|
||||||
metadata Json?
|
metadata Json?
|
||||||
ipAddress String?
|
ipAddress String?
|
||||||
userAgent String?
|
userAgent String?
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@ -429,8 +435,8 @@ model KPISnapshot {
|
|||||||
metricName String
|
metricName String
|
||||||
metricValue Float
|
metricValue Float
|
||||||
metadata Json?
|
metadata Json?
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@index([metricName])
|
@@index([metricName])
|
||||||
@@index([date])
|
@@index([date])
|
||||||
@@ -441,23 +447,23 @@ model KPISnapshot {
|
|||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
model WaitlistEntry {
|
model WaitlistEntry {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
email String
|
email String
|
||||||
name String?
|
name String?
|
||||||
source String? // landing_page, blog, referral, social, paid_search
|
source String? // landing_page, blog, referral, social, paid_search
|
||||||
tier SubscriptionTier? // interest level
|
tier SubscriptionTier? // interest level
|
||||||
utmSource String?
|
utmSource String?
|
||||||
utmMedium String?
|
utmMedium String?
|
||||||
utmCampaign String?
|
utmCampaign String?
|
||||||
metadata Json? // Browser, device, location, etc.
|
metadata Json? // Browser, device, location, etc.
|
||||||
|
|
||||||
// Conversion tracking
|
// Conversion tracking
|
||||||
convertedAt DateTime?
|
convertedAt DateTime?
|
||||||
convertedToUserId String?
|
convertedToUserId String?
|
||||||
convertedToSubscriptionId String?
|
convertedToSubscriptionId String?
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([email])
|
@@index([email])
|
||||||
@@index([source])
|
@@index([source])
|
||||||
@@ -465,22 +471,127 @@ model WaitlistEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model BlogPost {
|
model BlogPost {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
slug String @unique
|
slug String @unique
|
||||||
title String
|
title String
|
||||||
excerpt String?
|
excerpt String?
|
||||||
content String
|
content String
|
||||||
authorName String?
|
authorName String?
|
||||||
coverImageUrl String?
|
coverImageUrl String?
|
||||||
tags String[] // Array of tag strings
|
tags String[] // Array of tag strings
|
||||||
published Boolean @default(false)
|
published Boolean @default(false)
|
||||||
publishedAt DateTime?
|
publishedAt DateTime?
|
||||||
viewCount Int @default(0)
|
viewCount Int @default(0)
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([slug])
|
@@index([slug])
|
||||||
@@index([published, publishedAt])
|
@@index([published, publishedAt])
|
||||||
@@index([tags])
|
@@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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Re-export Prisma client
|
// Re-export Prisma client
|
||||||
export { prisma } from './client';
|
export { prisma } from './client.js';
|
||||||
|
|
||||||
// Export types
|
// Export types
|
||||||
export type {
|
export type {
|
||||||
@@ -18,4 +18,4 @@ export type {
|
|||||||
SpamRule,
|
SpamRule,
|
||||||
AuditLog,
|
AuditLog,
|
||||||
KPISnapshot,
|
KPISnapshot,
|
||||||
} from './client';
|
} from './client.js';
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export { EmailService } from './services/email.service';
|
export { EmailService } from './services/email.service';
|
||||||
export { SMSService } from './services/sms.service';
|
export { SMSService } from './services/sms.service';
|
||||||
export { PushService } from './services/push.service';
|
export { PushService } from './services/push.service';
|
||||||
|
export { APNSService } from './services/apns.service';
|
||||||
export { NotificationService, RateLimitResult } from './services/notification.service';
|
export { NotificationService, RateLimitResult } from './services/notification.service';
|
||||||
export { RedisService } from './services/redis.service';
|
export { RedisService } from './services/redis.service';
|
||||||
export { TemplateService } from './services/template.service';
|
export { TemplateService } from './services/template.service';
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export class EmailService {
|
|||||||
data: att.content,
|
data: att.content,
|
||||||
contentType: att.mimeType,
|
contentType: att.mimeType,
|
||||||
})),
|
})),
|
||||||
});
|
} as any);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export class SMSService {
|
|||||||
from: notification.from || config.twilio.messagingServiceSid,
|
from: notification.from || config.twilio.messagingServiceSid,
|
||||||
to: notification.to,
|
to: notification.to,
|
||||||
metadata: notification.metadata,
|
metadata: notification.metadata,
|
||||||
});
|
} as any);
|
||||||
|
|
||||||
this.sentCount.set(rateLimitKey, currentCount + 1);
|
this.sentCount.set(rateLimitKey, currentCount + 1);
|
||||||
|
|
||||||
|
|||||||
@@ -120,6 +120,67 @@ export const DefaultEmailTemplates: TemplateDefinition[] = [
|
|||||||
{ name: 'pdf_url', type: 'string', required: true },
|
{ 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[] = [
|
export const DefaultSMSTemplates: TemplateDefinition[] = [
|
||||||
|
|||||||
11772
pnpm-lock.yaml
generated
11772
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ import {
|
|||||||
SchedulerConfig,
|
SchedulerConfig,
|
||||||
ScheduledScanResult,
|
ScheduledScanResult,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
// @ts-expect-error uuid v10 ships with its own types but module resolution is complex
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
const DEFAULT_SCHEDULER_CONFIG: SchedulerConfig = {
|
const DEFAULT_SCHEDULER_CONFIG: SchedulerConfig = {
|
||||||
@@ -72,6 +73,7 @@ export class HomeTitleSchedulerService {
|
|||||||
const scanId = uuidv4();
|
const scanId = uuidv4();
|
||||||
const startedAt = new Date().toISOString();
|
const startedAt = new Date().toISOString();
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
let propertiesScanned = 0;
|
||||||
let changesDetected = 0;
|
let changesDetected = 0;
|
||||||
let alertsCreated = 0;
|
let alertsCreated = 0;
|
||||||
let notificationsSent = 0;
|
let notificationsSent = 0;
|
||||||
@@ -95,6 +97,7 @@ export class HomeTitleSchedulerService {
|
|||||||
const propertySnapshots = await this.fetchLatestSnapshots(
|
const propertySnapshots = await this.fetchLatestSnapshots(
|
||||||
subscription.userId,
|
subscription.userId,
|
||||||
);
|
);
|
||||||
|
propertiesScanned += propertySnapshots.length;
|
||||||
|
|
||||||
for (const snapshot of propertySnapshots) {
|
for (const snapshot of propertySnapshots) {
|
||||||
const previousSnapshot = await this.fetchPreviousSnapshot(
|
const previousSnapshot = await this.fetchPreviousSnapshot(
|
||||||
@@ -107,7 +110,7 @@ export class HomeTitleSchedulerService {
|
|||||||
|
|
||||||
const result = detectChanges(previousSnapshot, snapshot);
|
const result = detectChanges(previousSnapshot, snapshot);
|
||||||
|
|
||||||
if (shouldTriggerAlert(result, 'moderate')) {
|
if (shouldTriggerAlert(result, 'warning')) {
|
||||||
changesDetected++;
|
changesDetected++;
|
||||||
|
|
||||||
const alert = await homeTitleAlertPipeline.processChangeDetection(
|
const alert = await homeTitleAlertPipeline.processChangeDetection(
|
||||||
@@ -140,7 +143,7 @@ export class HomeTitleSchedulerService {
|
|||||||
|
|
||||||
const scanResult: ScheduledScanResult = {
|
const scanResult: ScheduledScanResult = {
|
||||||
scanId,
|
scanId,
|
||||||
propertiesScanned: changesDetected,
|
propertiesScanned,
|
||||||
changesDetected,
|
changesDetected,
|
||||||
alertsCreated,
|
alertsCreated,
|
||||||
notificationsSent,
|
notificationsSent,
|
||||||
|
|||||||
Reference in New Issue
Block a user