Compare commits
21 Commits
b1cfce3661
...
f118d3a4f3
| Author | SHA1 | Date | |
|---|---|---|---|
| f118d3a4f3 | |||
| a8a5930ced | |||
| 06ca3ec0cf | |||
| 986941e201 | |||
| 6a8d3648d8 | |||
| 64b70073ec | |||
| 90a223bc79 | |||
| a071aa736e | |||
|
|
7fb8b83810 | ||
| e72a0ba5cf | |||
| 7410813f4e | |||
| e9e547be78 | |||
|
|
bd881045f4 | ||
| 590e15e66e | |||
| 9f65ebce5d | |||
| d6f574ff8e | |||
| 24c31f1b1b | |||
| 7c2b585c16 | |||
| cba5390309 | |||
| 7ed1a340b9 | |||
| 08fedf55e6 |
38
.env.example
38
.env.example
@@ -27,3 +27,41 @@ SENTRY_TRACES_SAMPLE_RATE="0.1"
|
||||
# Google Analytics 4
|
||||
GA4_MEASUREMENT_ID=""
|
||||
GA4_API_SECRET=""
|
||||
|
||||
# Mixpanel Product Analytics
|
||||
MIXPANEL_TOKEN=""
|
||||
MIXPANEL_API_SECRET=""
|
||||
ANALYTICS_ENV="development"
|
||||
|
||||
# ============================================
|
||||
# Push Notifications Configuration
|
||||
# ============================================
|
||||
|
||||
# Firebase Cloud Messaging (FCM) - Android
|
||||
FCM_PROJECT_ID=""
|
||||
FCM_CLIENT_EMAIL=""
|
||||
FCM_PRIVATE_KEY=""
|
||||
|
||||
# Apple Push Notification Service (APNs) - iOS
|
||||
APNS_KEY_ID=""
|
||||
APNS_TEAM_ID=""
|
||||
APNS_BUNDLE_ID=""
|
||||
APNS_KEY=""
|
||||
|
||||
# Twilio - SMS (optional)
|
||||
TWILIO_ACCOUNT_SID=""
|
||||
TWILIO_AUTH_TOKEN=""
|
||||
TWILIO_MESSAGING_SERVICE_SID=""
|
||||
|
||||
# Notification Rate Limits
|
||||
PUSH_RATE_LIMIT=100
|
||||
EMAIL_RATE_LIMIT=60
|
||||
SMS_RATE_LIMIT=30
|
||||
RATE_LIMIT_WINDOW_SECONDS=60
|
||||
|
||||
# Frontend Environment Variables (Vite)
|
||||
# Add these to packages/web/.env or your frontend .env files:
|
||||
# VITE_MIXPANEL_TOKEN=<same-as-backend-token>
|
||||
# VITE_GA_MEASUREMENT_ID=<same-as-backend-id>
|
||||
# VITE_META_PIXEL_ID=""
|
||||
# VITE_LINKEDIN_PARTNER_ID=""
|
||||
|
||||
@@ -1,57 +1,149 @@
|
||||
# ShieldAI Mixpanel Analytics Configuration
|
||||
|
||||
## Current Implementation Status
|
||||
## Implementation Complete ✅
|
||||
|
||||
✅ **Already Implemented:**
|
||||
The Mixpanel analytics infrastructure is fully implemented and ready for use. This document covers setup and usage.
|
||||
|
||||
### Backend (packages/shared-analytics)
|
||||
- Full MixpanelService with Segment analytics integration
|
||||
- Event taxonomy defined in `EventType` enum:
|
||||
- User events: `user_signed_up`, `user_logged_in`, `user_upgraded`, etc.
|
||||
- Subscription events: `subscription_created`, `subscription_cancelled`, etc.
|
||||
- Product-specific events: DarkWatch, VoicePrint, SpamShield events
|
||||
- User identification and group tracking
|
||||
- KPI definitions (MAU, MRR, conversion rate, churn, etc.)
|
||||
---
|
||||
|
||||
### Frontend (packages/web)
|
||||
- Analytics hook (`useAnalytics.ts`) with:
|
||||
- Mixpanel initialization via `VITE_MIXPANEL_TOKEN`
|
||||
- Event tracking (`trackEvent`)
|
||||
- Page view tracking (`trackPageView`)
|
||||
- Waitlist signup tracking (`trackWaitlistSignup`)
|
||||
- GA4, Meta Pixel, and LinkedIn Insight integration
|
||||
## What's Implemented
|
||||
|
||||
## Required Actions
|
||||
### Backend (`packages/shared-analytics`)
|
||||
- **MixpanelService**: Full implementation using Segment analytics-node SDK
|
||||
- **Event Taxonomy**: 30+ events defined in `EventType` enum covering:
|
||||
- **User Events**: `user_signed_up`, `user_logged_in`, `user_upgraded`, `user_downgraded`
|
||||
- **Subscription Events**: `subscription_created`, `subscription_updated`, `subscription_cancelled`, `subscription_renewed`
|
||||
- **DarkWatch Events**: `dark_web_scan_started`, `dark_web_scan_completed`, `exposure_detected`, `exposure_resolved`, `watchlist_item_added`, `watchlist_item_removed`
|
||||
- **VoicePrint Events**: `voice_enrolled`, `voice_analyzed`, `voice_match_found`, `synthetic_voice_detected`
|
||||
- **SpamShield Events**: `call_analyzed`, `sms_analyzed`, `spam_blocked`, `spam_flagged`, `spam_feedback_submitted`
|
||||
- **KPI Events**: `mrr_updated`, `conversion_occurred`, `churn_occurred`, `referral_sent`, `referral_converted`
|
||||
- **User Identification**: `identify()` and `group()` methods for user segmentation
|
||||
- **KPI Definitions**: MAU, MRR, conversion rate, churn, CAC, LTV, NPS, viral coefficient
|
||||
- **Data Validation**: Zod schema for event properties
|
||||
|
||||
### 1. Create Mixpanel Project (Manual - Requires Account)
|
||||
- Sign up for Mixpanel at https://mixpanel.com
|
||||
- Create project: "ShieldAI"
|
||||
- Get project token from Project Settings → Project Token
|
||||
### Frontend (`packages/web/src/hooks/useAnalytics.ts`)
|
||||
- **Initialization**: Auto-loads Mixpanel script via `initMixpanel()`
|
||||
- **Event Tracking**: `trackEvent(name, params)` for generic events
|
||||
- **Page View Tracking**: `trackPageView(path, title)` with automatic page title
|
||||
- **Waitlist Tracking**: `trackWaitlistSignup(email, source, tier)` with Meta Pixel integration
|
||||
- **Multi-Platform Support**: GA4, Meta Pixel, LinkedIn Insight included
|
||||
|
||||
### 2. Configure Environment Variables
|
||||
---
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### Step 1: Create Mixpanel Account (Manual)
|
||||
1. Go to https://mixpanel.com
|
||||
2. Sign up for an account (free tier: 100K monthly tracked users)
|
||||
3. Create a new project named "ShieldAI"
|
||||
4. Copy the **Project Token** from Project Settings → Project Token
|
||||
|
||||
### Step 2: Configure Backend Environment Variables
|
||||
Add to `ShieldAI/.env`:
|
||||
```bash
|
||||
# Backend (.env)
|
||||
MIXPANEL_TOKEN=<your-mixpanel-token>
|
||||
MIXPANEL_API_SECRET=<optional-api-secret>
|
||||
|
||||
# Frontend (.env)
|
||||
VITE_MIXPANEL_TOKEN=<your-mixpanel-token>
|
||||
# Mixpanel (Backend)
|
||||
MIXPANEL_TOKEN=<your-mixpanel-project-token>
|
||||
MIXPANEL_API_SECRET=<optional-api-secret-for-server-side>
|
||||
ANALYTICS_ENV=development # or production, staging
|
||||
```
|
||||
|
||||
### 3. Event Taxonomy Documentation
|
||||
See `packages/shared-analytics/src/config/analytics.config.ts` for full event definitions.
|
||||
### Step 3: Configure Frontend Environment Variables
|
||||
Create or update `ShieldAI/packages/web/.env`:
|
||||
```bash
|
||||
# Mixpanel (Frontend - Vite)
|
||||
VITE_MIXPANEL_TOKEN=<same-project-token-as-backend>
|
||||
```
|
||||
|
||||
### 4. User Property Definitions
|
||||
Standard properties tracked:
|
||||
- `userId`: User identifier
|
||||
- `sessionId`: Session identifier
|
||||
- `platform`: web, mobile, desktop, api
|
||||
- `version`: App version
|
||||
- `environment`: development, production, staging
|
||||
### Step 4: Verify Integration
|
||||
Backend usage example:
|
||||
```typescript
|
||||
import { mixpanelService, EventType } from '@shieldsai/shared-analytics';
|
||||
|
||||
## Integration Points
|
||||
// Track an event
|
||||
await mixpanelService.track(EventType.USER_SIGNED_UP, userId, {
|
||||
plan: 'premium',
|
||||
referrer: 'google',
|
||||
});
|
||||
|
||||
- `DarkWatch`: Exposure detection events
|
||||
- `SpamShield`: Spam detection and blocking events
|
||||
- `VoicePrint`: Voice enrollment and analysis events
|
||||
- `Waitlist`: Signup tracking with source attribution
|
||||
// Identify a user
|
||||
await mixpanelService.identify(userId, {
|
||||
email: 'user@example.com',
|
||||
tier: 'premium',
|
||||
});
|
||||
```
|
||||
|
||||
Frontend usage example:
|
||||
```typescript
|
||||
import { trackEvent, trackWaitlistSignup, trackPageView } from './hooks/useAnalytics';
|
||||
|
||||
// Track a page view
|
||||
trackPageView('/waitlist', 'Waitlist Signup');
|
||||
|
||||
// Track a signup
|
||||
trackWaitlistSignup('user@example.com', 'landing_page', 'free');
|
||||
|
||||
// Track a custom event
|
||||
trackEvent('feature_used', { feature: 'darkwatch_scan', duration: 45 });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Event Properties Schema
|
||||
|
||||
All events support these standard properties (validated via Zod):
|
||||
- `userId` (string, optional): User identifier
|
||||
- `sessionId` (string, optional): Session identifier
|
||||
- `timestamp` (Date, optional): Event timestamp
|
||||
- `platform` (enum: 'web' | 'mobile' | 'desktop' | 'api', optional): Event source
|
||||
- `version` (string, optional): App version
|
||||
- `environment` (string, optional): deployment environment
|
||||
|
||||
Additional custom properties are accepted and passed through to Mixpanel.
|
||||
|
||||
---
|
||||
|
||||
## KPI Definitions
|
||||
|
||||
| KPI | Description | Calculation |
|
||||
|-----|-------------|-------------|
|
||||
| MAU | Monthly Active Users | COUNT(DISTINCT userId) WHERE timestamp > NOW() - 30 DAYS |
|
||||
| Paying Users | Active subscriptions | COUNT(DISTINCT userId) WHERE subscription.status = "active" |
|
||||
| MRR | Monthly Recurring Revenue | SUM(subscription.amount) WHERE subscription.status = "active" |
|
||||
| Conversion Rate | Free → Paid | COUNT(upgrade events) / COUNT(signup events) |
|
||||
| Churn Rate | Cancellations | COUNT(cancel events) / COUNT(active subscriptions) |
|
||||
| CAC | Customer Acquisition Cost | Total marketing spend / COUNT(new paying users) |
|
||||
| LTV | Lifetime Value | Average subscription amount / Churn rate |
|
||||
| NPS | Net Promoter Score | % Promoters - % Detractors |
|
||||
| Viral Coefficient | Referral multiplier | COUNT(referral events) / COUNT(users) |
|
||||
|
||||
---
|
||||
|
||||
## Alert Thresholds
|
||||
|
||||
Configured thresholds for monitoring:
|
||||
- **Churn**: Warning >5%, Critical >10%
|
||||
- **Conversion Rate**: Warning <2%, Critical <1%
|
||||
- **MRR**: Warning <90% target, Critical <80% target
|
||||
- **NPS**: Warning <50, Critical <40
|
||||
- **Viral Coefficient**: Warning <0.4, Critical <0.3
|
||||
|
||||
---
|
||||
|
||||
## Files Modified
|
||||
|
||||
- `packages/shared-analytics/src/config/analytics.config.ts` - Event taxonomy & KPIs
|
||||
- `packages/shared-analytics/src/services/mixpanel.service.ts` - Mixpanel service
|
||||
- `packages/shared-analytics/src/index.ts` - Exports
|
||||
- `packages/web/src/hooks/useAnalytics.ts` - Frontend analytics hook
|
||||
- `.env.example` - Environment variable templates
|
||||
- `docs/MIXPANEL_ANALYTICS.md` - This documentation
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Create Mixpanel account** (requires human action - no API for account creation)
|
||||
2. **Add tokens to `.env`** files
|
||||
3. **Test event tracking** in development
|
||||
4. **Set up Mixpanel dashboards** for KPI monitoring
|
||||
5. **Configure Mixpanel alerts** for threshold breaches
|
||||
|
||||
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/
|
||||
@@ -9,23 +9,28 @@
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^10.0.1",
|
||||
"@fastify/helmet": "^13.0.1",
|
||||
"@fastify/multipart": "^7.7.3",
|
||||
"@fastify/rate-limit": "^9.0.0",
|
||||
"@fastify/sensible": "^6.0.1",
|
||||
"fastify-raw-body": "^5.0.0",
|
||||
"@fastify/swagger": "^9.4.0",
|
||||
"@fastify/swagger-ui": "^5.2.0",
|
||||
"@shieldai/correlation": "workspace:*",
|
||||
"@shieldai/darkwatch": "workspace:*",
|
||||
"@shieldai/db": "workspace:*",
|
||||
"@shieldai/monitoring": "workspace:*",
|
||||
"@shieldai/removebrokers": "workspace:*",
|
||||
"@shieldai/report": "workspace:*",
|
||||
"@shieldsai/shared-auth": "workspace:*",
|
||||
"@shieldai/shared-notifications": "workspace:*",
|
||||
"@shieldai/types": "workspace:*",
|
||||
"@shieldai/voiceprint": "workspace:*",
|
||||
"bullmq": "^5.24.0",
|
||||
"fastify": "^5.2.0",
|
||||
"ioredis": "^5.4.0"
|
||||
"ioredis": "^5.4.0",
|
||||
"jsonwebtoken": "^9.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || process.env.NEXTAUTH_SECRET;
|
||||
|
||||
if (!JWT_SECRET && process.env.NODE_ENV === 'production') {
|
||||
console.error('JWT_SECRET or NEXTAUTH_SECRET must be set in production');
|
||||
}
|
||||
|
||||
export interface AuthRequest extends FastifyRequest {
|
||||
user?: {
|
||||
@@ -27,17 +34,35 @@ export async function authMiddleware(fastify: FastifyInstance) {
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
const token = authHeader.slice(7);
|
||||
try {
|
||||
// In production, decode and verify JWT
|
||||
// For now, we'll attach a placeholder user
|
||||
if (!JWT_SECRET) {
|
||||
throw new Error('JWT_SECRET not configured');
|
||||
}
|
||||
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as {
|
||||
id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
organizationId?: string;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
};
|
||||
|
||||
authReq.user = {
|
||||
id: 'user-placeholder',
|
||||
email: 'user@example.com',
|
||||
role: 'user',
|
||||
id: decoded.id,
|
||||
email: decoded.email,
|
||||
role: decoded.role,
|
||||
organizationId: decoded.organizationId,
|
||||
};
|
||||
authReq.authType = 'jwt';
|
||||
return;
|
||||
} catch (err) {
|
||||
// JWT invalid, continue to API key check
|
||||
if (err instanceof jwt.JsonWebTokenError) {
|
||||
throw { statusCode: 401, message: 'Invalid token' };
|
||||
}
|
||||
if (err instanceof jwt.TokenExpiredError) {
|
||||
throw { statusCode: 401, message: 'Token expired', expiredAt: err.expiredAt };
|
||||
}
|
||||
throw { statusCode: 401, message: 'Authentication failed' };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
3629
packages/api/src/openapi/spec.json
Normal file
3629
packages/api/src/openapi/spec.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -7,39 +7,97 @@ function getUserId(request: FastifyRequest): string | undefined {
|
||||
return (request.user as AuthUser | undefined)?.id;
|
||||
}
|
||||
|
||||
const timeWindowSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
timeWindow: { type: "integer", minimum: 1, maximum: 10080 },
|
||||
},
|
||||
};
|
||||
|
||||
const paginatedQuerySchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
timeWindow: { type: "integer", minimum: 1, maximum: 10080 },
|
||||
limit: { type: "integer", minimum: 1, maximum: 200 },
|
||||
offset: { type: "integer", minimum: 0, maximum: 10000 },
|
||||
},
|
||||
};
|
||||
|
||||
export function correlationRoutes(fastify: FastifyInstance) {
|
||||
fastify.get("/dashboard", async (request, reply) => {
|
||||
const userId = getUserId(request);
|
||||
if (!userId || userId === "anonymous") {
|
||||
return reply.code(401).send({ error: "User not authenticated" });
|
||||
fastify.get(
|
||||
"/dashboard",
|
||||
{
|
||||
schema: {
|
||||
...timeWindowSchema,
|
||||
response: {
|
||||
"400": {
|
||||
type: "object",
|
||||
properties: {
|
||||
error: { type: "string" },
|
||||
},
|
||||
},
|
||||
"401": {
|
||||
type: "object",
|
||||
properties: {
|
||||
error: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = getUserId(request);
|
||||
if (!userId || userId === "anonymous") {
|
||||
return reply.code(401).send({ error: "User not authenticated" });
|
||||
}
|
||||
|
||||
const query = request.query as Record<string, string | number>;
|
||||
const timeWindow =
|
||||
typeof query.timeWindow === "number" ? query.timeWindow : 60;
|
||||
const data = await correlationService.getDashboardData(userId, timeWindow);
|
||||
return reply.send(data);
|
||||
}
|
||||
);
|
||||
|
||||
const timeWindow =
|
||||
parseInt(
|
||||
(request.query as Record<string, string>).timeWindow as string
|
||||
) || 60;
|
||||
const data = await correlationService.getDashboardData(userId, timeWindow);
|
||||
return reply.send(data);
|
||||
});
|
||||
fastify.get(
|
||||
"/groups",
|
||||
{
|
||||
schema: {
|
||||
...paginatedQuerySchema,
|
||||
response: {
|
||||
"400": {
|
||||
type: "object",
|
||||
properties: {
|
||||
error: { type: "string" },
|
||||
},
|
||||
},
|
||||
"401": {
|
||||
type: "object",
|
||||
properties: {
|
||||
error: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = getUserId(request);
|
||||
if (!userId || userId === "anonymous") {
|
||||
return reply.code(401).send({ error: "User not authenticated" });
|
||||
}
|
||||
|
||||
fastify.get("/groups", async (request, reply) => {
|
||||
const userId = getUserId(request);
|
||||
if (!userId || userId === "anonymous") {
|
||||
return reply.code(401).send({ error: "User not authenticated" });
|
||||
const query = request.query as Record<string, string | number>;
|
||||
const result = await correlationService.getCorrelationGroups({
|
||||
userId,
|
||||
status: (query.status as any) || undefined,
|
||||
timeWindowMinutes:
|
||||
typeof query.timeWindow === "number" ? query.timeWindow : 60,
|
||||
limit: typeof query.limit === "number" ? query.limit : 50,
|
||||
offset: typeof query.offset === "number" ? query.offset : 0,
|
||||
});
|
||||
return reply.send(result);
|
||||
}
|
||||
|
||||
const query = request.query as Record<string, string>;
|
||||
const result = await correlationService.getCorrelationGroups({
|
||||
userId,
|
||||
status: (query.status as any) || undefined,
|
||||
timeWindowMinutes: query.timeWindow
|
||||
? parseInt(query.timeWindow)
|
||||
: 60,
|
||||
limit: query.limit ? parseInt(query.limit) : 50,
|
||||
offset: query.offset ? parseInt(query.offset) : 0,
|
||||
});
|
||||
return reply.send(result);
|
||||
});
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
"/groups/:groupId",
|
||||
@@ -114,26 +172,47 @@ export function correlationRoutes(fastify: FastifyInstance) {
|
||||
}
|
||||
);
|
||||
|
||||
fastify.get("/alerts", async (request, reply) => {
|
||||
const userId = getUserId(request);
|
||||
if (!userId || userId === "anonymous") {
|
||||
return reply.code(401).send({ error: "User not authenticated" });
|
||||
}
|
||||
fastify.get(
|
||||
"/alerts",
|
||||
{
|
||||
schema: {
|
||||
...paginatedQuerySchema,
|
||||
response: {
|
||||
"400": {
|
||||
type: "object",
|
||||
properties: {
|
||||
error: { type: "string" },
|
||||
},
|
||||
},
|
||||
"401": {
|
||||
type: "object",
|
||||
properties: {
|
||||
error: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = getUserId(request);
|
||||
if (!userId || userId === "anonymous") {
|
||||
return reply.code(401).send({ error: "User not authenticated" });
|
||||
}
|
||||
|
||||
const query = request.query as Record<string, string>;
|
||||
const result = await correlationService.getCorrelatedAlerts({
|
||||
userId,
|
||||
source: (query.source as any) || undefined,
|
||||
category: (query.category as any) || undefined,
|
||||
severity: (query.severity as any) || undefined,
|
||||
timeWindowMinutes: query.timeWindow
|
||||
? parseInt(query.timeWindow)
|
||||
: 60,
|
||||
limit: query.limit ? parseInt(query.limit) : 50,
|
||||
offset: query.offset ? parseInt(query.offset) : 0,
|
||||
});
|
||||
return reply.send(result);
|
||||
});
|
||||
const query = request.query as Record<string, string | number>;
|
||||
const result = await correlationService.getCorrelatedAlerts({
|
||||
userId,
|
||||
source: (query.source as any) || undefined,
|
||||
category: (query.category as any) || undefined,
|
||||
severity: (query.severity as any) || undefined,
|
||||
timeWindowMinutes:
|
||||
typeof query.timeWindow === "number" ? query.timeWindow : 60,
|
||||
limit: typeof query.limit === "number" ? query.limit : 50,
|
||||
offset: typeof query.offset === "number" ? query.offset : 0,
|
||||
});
|
||||
return reply.send(result);
|
||||
}
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/ingest/darkwatch",
|
||||
|
||||
342
packages/api/src/routes/device.routes.ts
Normal file
342
packages/api/src/routes/device.routes.ts
Normal file
@@ -0,0 +1,342 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { prisma } from '@shieldai/db';
|
||||
|
||||
// In-memory rate limiter for device registration
|
||||
const registrationAttempts = new Map<string, { count: number; resetAt: number }>();
|
||||
const REGISTRATION_RATE_LIMIT = 10; // max registrations per window
|
||||
const REGISTRATION_WINDOW_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
function checkRegistrationRateLimit(key: string): boolean {
|
||||
const now = Date.now();
|
||||
const record = registrationAttempts.get(key);
|
||||
|
||||
if (!record || now > record.resetAt) {
|
||||
registrationAttempts.set(key, { count: 1, resetAt: now + REGISTRATION_WINDOW_MS });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (record.count >= REGISTRATION_RATE_LIMIT) {
|
||||
return false;
|
||||
}
|
||||
|
||||
record.count++;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Cleanup stale rate limit entries every 5 minutes
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, record] of registrationAttempts.entries()) {
|
||||
if (now > record.resetAt) {
|
||||
registrationAttempts.delete(key);
|
||||
}
|
||||
}
|
||||
}, REGISTRATION_WINDOW_MS);
|
||||
|
||||
export async function deviceRoutes(fastify: FastifyInstance) {
|
||||
// Register device for push notifications
|
||||
fastify.post(
|
||||
'/devices/register',
|
||||
{
|
||||
preHandler: async (request, reply) => {
|
||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
||||
const userId = authReq.user?.id;
|
||||
if (!userId) {
|
||||
return reply.status(401).send({ error: 'Authentication required' });
|
||||
}
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
||||
const { platform, fcmToken, apnsToken, appVersion, osVersion, deviceModel } = request.body as {
|
||||
platform: 'ios' | 'android';
|
||||
fcmToken?: string;
|
||||
apnsToken?: string;
|
||||
appVersion: string;
|
||||
osVersion: string;
|
||||
deviceModel?: string;
|
||||
};
|
||||
|
||||
if (!authReq.user?.id) {
|
||||
return reply.status(401).send({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
if (!platform || !appVersion || !osVersion) {
|
||||
return reply.status(400).send({
|
||||
error: 'Missing required fields',
|
||||
required: ['platform', 'appVersion', 'osVersion'],
|
||||
});
|
||||
}
|
||||
|
||||
// Rate limit registration per user
|
||||
if (!checkRegistrationRateLimit(authReq.user.id)) {
|
||||
return reply.status(429).send({
|
||||
error: 'Too many registration attempts',
|
||||
retryAfter: Math.ceil(REGISTRATION_WINDOW_MS / 1000),
|
||||
});
|
||||
}
|
||||
|
||||
if (platform === 'android' && !fcmToken) {
|
||||
return reply.status(400).send({
|
||||
error: 'FCM token required for Android devices',
|
||||
});
|
||||
}
|
||||
|
||||
if (platform === 'ios' && !apnsToken) {
|
||||
return reply.status(400).send({
|
||||
error: 'APNs token required for iOS devices',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Determine device type based on platform
|
||||
const deviceType = platform === 'ios' || platform === 'android' ? 'mobile' : 'web';
|
||||
|
||||
// Determine the token to store
|
||||
const deviceToken = platform === 'ios' ? apnsToken! : fcmToken!;
|
||||
|
||||
// Upsert device registration to handle token rotation and re-registration
|
||||
const deviceRegistration = await prisma.deviceToken.upsert({
|
||||
where: { token: deviceToken },
|
||||
create: {
|
||||
userId: authReq.user.id,
|
||||
deviceType,
|
||||
token: deviceToken,
|
||||
platform,
|
||||
appVersion,
|
||||
osVersion,
|
||||
model: deviceModel,
|
||||
isActive: true,
|
||||
lastUsedAt: new Date(),
|
||||
},
|
||||
update: {
|
||||
userId: authReq.user.id,
|
||||
deviceType,
|
||||
platform,
|
||||
appVersion,
|
||||
osVersion,
|
||||
model: deviceModel,
|
||||
isActive: true,
|
||||
lastUsedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
device: {
|
||||
deviceId: deviceRegistration.id,
|
||||
platform: deviceRegistration.platform,
|
||||
registeredAt: deviceRegistration.createdAt.toISOString(),
|
||||
},
|
||||
message: 'Device registered successfully',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to register device:', error);
|
||||
reply.status(500).send({
|
||||
error: 'Failed to register device',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Update device push tokens
|
||||
fastify.put(
|
||||
'/devices/:deviceId/tokens',
|
||||
{
|
||||
preHandler: async (request, reply) => {
|
||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
||||
const userId = authReq.user?.id;
|
||||
if (!userId) {
|
||||
return reply.status(401).send({ error: 'Authentication required' });
|
||||
}
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
||||
const { deviceId } = request.params as { deviceId: string };
|
||||
const { fcmToken, apnsToken, deviceModel } = request.body as {
|
||||
fcmToken?: string;
|
||||
apnsToken?: string;
|
||||
deviceModel?: string;
|
||||
};
|
||||
|
||||
if (!authReq.user?.id) {
|
||||
return reply.status(401).send({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
if (!fcmToken && !apnsToken) {
|
||||
return reply.status(400).send({
|
||||
error: 'At least one token is required',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Find the device first
|
||||
const existingDevice = await prisma.deviceToken.findFirst({
|
||||
where: {
|
||||
id: deviceId,
|
||||
userId: authReq.user.id,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingDevice) {
|
||||
return reply.status(404).send({
|
||||
error: 'Device not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Determine which token to update based on platform
|
||||
const updateData: {
|
||||
token: string;
|
||||
appVersion?: string;
|
||||
osVersion?: string;
|
||||
model?: string;
|
||||
lastUsedAt: Date;
|
||||
} = {
|
||||
token: existingDevice.platform === 'ios' ? (apnsToken || existingDevice.token) : (fcmToken || existingDevice.token),
|
||||
lastUsedAt: new Date(),
|
||||
};
|
||||
|
||||
if (deviceModel) {
|
||||
updateData.model = deviceModel;
|
||||
}
|
||||
|
||||
// Update device tokens in database
|
||||
const device = await prisma.deviceToken.update({
|
||||
where: {
|
||||
id: deviceId,
|
||||
userId: authReq.user.id,
|
||||
},
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
device: {
|
||||
deviceId: device.id,
|
||||
platform: device.platform,
|
||||
lastActiveAt: device.lastUsedAt.toISOString(),
|
||||
},
|
||||
message: 'Device tokens updated successfully',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to update device tokens:', error);
|
||||
reply.status(500).send({
|
||||
error: 'Failed to update device tokens',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get user's registered devices
|
||||
fastify.get(
|
||||
'/devices',
|
||||
{
|
||||
preHandler: async (request, reply) => {
|
||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
||||
const userId = authReq.user?.id;
|
||||
if (!userId) {
|
||||
return reply.status(401).send({ error: 'Authentication required' });
|
||||
}
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
||||
|
||||
if (!authReq.user?.id) {
|
||||
return reply.status(401).send({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch devices from database
|
||||
const devices = await prisma.deviceToken.findMany({
|
||||
where: {
|
||||
userId: authReq.user.id,
|
||||
isActive: true,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
platform: true,
|
||||
appVersion: true,
|
||||
osVersion: true,
|
||||
model: true,
|
||||
lastUsedAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: {
|
||||
lastUsedAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
devices: devices.map((d) => ({
|
||||
deviceId: d.id,
|
||||
platform: d.platform,
|
||||
appVersion: d.appVersion,
|
||||
osVersion: d.osVersion,
|
||||
model: d.model,
|
||||
lastActiveAt: d.lastUsedAt.toISOString(),
|
||||
registeredAt: d.createdAt.toISOString(),
|
||||
})),
|
||||
message: 'Device list retrieved',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch devices:', error);
|
||||
reply.status(500).send({
|
||||
error: 'Failed to fetch devices',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Deregister device
|
||||
fastify.delete(
|
||||
'/devices/:deviceId',
|
||||
{
|
||||
preHandler: async (request, reply) => {
|
||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
||||
const userId = authReq.user?.id;
|
||||
if (!userId) {
|
||||
return reply.status(401).send({ error: 'Authentication required' });
|
||||
}
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const authReq = request as FastifyRequest & { user?: { id: string } };
|
||||
const { deviceId } = request.params as { deviceId: string };
|
||||
|
||||
if (!authReq.user?.id) {
|
||||
return reply.status(401).send({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Soft delete by marking as inactive
|
||||
await prisma.deviceToken.update({
|
||||
where: {
|
||||
id: deviceId,
|
||||
userId: authReq.user.id,
|
||||
},
|
||||
data: {
|
||||
isActive: false,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Device deregistered successfully',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to deregister device:', error);
|
||||
reply.status(500).send({
|
||||
error: 'Failed to deregister device',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
463
packages/api/src/routes/hometitle.routes.ts
Normal file
463
packages/api/src/routes/hometitle.routes.ts
Normal file
@@ -0,0 +1,463 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { prisma, SubscriptionTier } from '@shieldai/db';
|
||||
import { detectChanges, shouldTriggerAlert } from '@shieldai/hometitle';
|
||||
import { homeTitleAlertPipeline } from '@shieldai/hometitle';
|
||||
import { AuthRequest } from '../auth.middleware';
|
||||
|
||||
const HOMETITLE_PROPERTY_LIMITS: Record<string, number> = {
|
||||
free: 0,
|
||||
basic: 0,
|
||||
plus: 3,
|
||||
premium: 5,
|
||||
};
|
||||
|
||||
const HOMETITLE_TIER_ORDER: Record<string, number> = {
|
||||
free: 0,
|
||||
basic: 1,
|
||||
plus: 2,
|
||||
premium: 3,
|
||||
};
|
||||
|
||||
/**
|
||||
* Middleware: require Premium tier for home title features.
|
||||
* Returns subscriptionId if user is Premium, otherwise replies with 402.
|
||||
*/
|
||||
async function requirePremiumTier(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
): Promise<string | null> {
|
||||
const authReq = request as AuthRequest;
|
||||
const userId = authReq.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
await reply.code(401).send({ error: 'User not authenticated' });
|
||||
return null;
|
||||
}
|
||||
|
||||
const subscription = await prisma.subscription.findFirst({
|
||||
where: { userId, status: 'active' },
|
||||
select: { id: true, tier: true },
|
||||
});
|
||||
|
||||
if (!subscription) {
|
||||
await reply.code(402).send({
|
||||
error: 'Subscription required',
|
||||
message: 'A home title monitoring subscription is required',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
if (subscription.tier !== 'premium') {
|
||||
const currentTier = HOMETITLE_TIER_ORDER[subscription.tier] ?? 0;
|
||||
const nextTier = 'plus';
|
||||
|
||||
await reply.code(402).send({
|
||||
error: 'Premium tier required',
|
||||
message: `Home title monitoring requires a Premium subscription. You are currently on ${subscription.tier}.`,
|
||||
currentTier: subscription.tier,
|
||||
upgradeTo: nextTier,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
return subscription.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check property limit for the user's tier.
|
||||
* Returns true if under limit, replies 400 if exceeded.
|
||||
*/
|
||||
async function checkPropertyLimit(
|
||||
reply: FastifyReply,
|
||||
subscriptionId: string,
|
||||
): Promise<boolean> {
|
||||
const subscription = await prisma.subscription.findUnique({
|
||||
where: { id: subscriptionId },
|
||||
select: { tier: true },
|
||||
});
|
||||
|
||||
if (!subscription) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const limit = HOMETITLE_PROPERTY_LIMITS[subscription.tier] ?? 0;
|
||||
const count = await prisma.watchlistItem.count({
|
||||
where: { subscriptionId, type: 'address' },
|
||||
});
|
||||
|
||||
if (count >= limit) {
|
||||
await reply.code(400).send({
|
||||
error: 'Property limit reached',
|
||||
message: `You have reached the maximum of ${limit} properties for your ${subscription.tier} tier.`,
|
||||
currentCount: count,
|
||||
limit,
|
||||
upgradeTo: 'premium',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function hometitleRoutes(fastify: FastifyInstance) {
|
||||
// GET /hometitle/properties - List monitored properties with status
|
||||
fastify.get('/properties', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const subscriptionId = await requirePremiumTier(request, reply);
|
||||
if (!subscriptionId) return;
|
||||
|
||||
try {
|
||||
const watchlistItems = await prisma.watchlistItem.findMany({
|
||||
where: { subscriptionId, type: 'address', isActive: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const itemIds = watchlistItems.map((item) => item.id);
|
||||
|
||||
// Batch query: fetch all recent alerts in one query
|
||||
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||
const allRecentAlerts = await prisma.alert.findMany({
|
||||
where: {
|
||||
subscriptionId,
|
||||
watchlistItemId: { in: itemIds },
|
||||
createdAt: { gte: sevenDaysAgo },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
// Group alerts by watchlistItemId
|
||||
const alertsByItem = new Map<string, typeof allRecentAlerts>();
|
||||
for (const alert of allRecentAlerts) {
|
||||
const arr = alertsByItem.get(alert.watchlistItemId) || [];
|
||||
arr.push(alert);
|
||||
alertsByItem.set(alert.watchlistItemId, arr);
|
||||
}
|
||||
|
||||
const properties = watchlistItems.map((item) => {
|
||||
const recentAlerts = (alertsByItem.get(item.id) || []).slice(0, 3);
|
||||
const hasRecentAlerts = recentAlerts.length > 0;
|
||||
const latestAlert = recentAlerts[0];
|
||||
|
||||
let status: 'monitored' | 'alert' | 'error' = 'monitored';
|
||||
if (hasRecentAlerts && latestAlert) {
|
||||
status = latestAlert.severity === 'CRITICAL' ? 'alert' : 'monitored';
|
||||
}
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
address: item.value,
|
||||
status,
|
||||
lastScan: null,
|
||||
lastChange: latestAlert ? latestAlert.createdAt : null,
|
||||
alertCount: recentAlerts.length,
|
||||
addedAt: item.createdAt,
|
||||
};
|
||||
});
|
||||
|
||||
return reply.send({ properties });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to list properties';
|
||||
return reply.code(500).send({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /hometitle/properties/stats - Dashboard widget stats (available to all active subscriptions)
|
||||
fastify.get('/properties/stats', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const authReq = request as AuthRequest;
|
||||
const userId = authReq.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
return reply.code(401).send({ error: 'User not authenticated' });
|
||||
}
|
||||
|
||||
const subscription = await prisma.subscription.findFirst({
|
||||
where: { userId, status: 'active' },
|
||||
select: { id: true, tier: true },
|
||||
});
|
||||
|
||||
if (!subscription) {
|
||||
return reply.code(404).send({ error: 'Active subscription not found' });
|
||||
}
|
||||
|
||||
try {
|
||||
const propertyCount = await prisma.watchlistItem.count({
|
||||
where: { subscriptionId: subscription.id, type: 'address', isActive: true },
|
||||
});
|
||||
|
||||
const limit = HOMETITLE_PROPERTY_LIMITS[subscription.tier] ?? 0;
|
||||
const isPremium = subscription.tier === 'premium';
|
||||
|
||||
// Count recent alerts (last 7 days)
|
||||
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||
const recentAlertCount = await prisma.alert.count({
|
||||
where: {
|
||||
subscriptionId: subscription.id,
|
||||
createdAt: { gte: sevenDaysAgo },
|
||||
},
|
||||
});
|
||||
|
||||
// Count critical alerts
|
||||
const criticalAlertCount = await prisma.alert.count({
|
||||
where: {
|
||||
subscriptionId: subscription.id,
|
||||
severity: 'CRITICAL',
|
||||
createdAt: { gte: sevenDaysAgo },
|
||||
},
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
monitoredProperties: propertyCount,
|
||||
tier: subscription.tier,
|
||||
isPremium,
|
||||
propertyLimit: limit,
|
||||
canAddMore: propertyCount < limit,
|
||||
recentAlertCount,
|
||||
criticalAlertCount,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to fetch stats';
|
||||
return reply.code(500).send({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /hometitle/properties - Add property to monitor
|
||||
fastify.post('/properties', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const subscriptionId = await requirePremiumTier(request, reply);
|
||||
if (!subscriptionId) return;
|
||||
|
||||
const body = request.body as { address: string };
|
||||
|
||||
if (!body.address || typeof body.address !== 'string') {
|
||||
return reply.code(400).send({
|
||||
error: 'Invalid request',
|
||||
message: 'Address is required',
|
||||
});
|
||||
}
|
||||
|
||||
// Validate address format (permissive: requires number + space + text)
|
||||
const addressPattern = /^\d+[\s,].+$/i;
|
||||
if (!addressPattern.test(body.address.trim())) {
|
||||
return reply.code(400).send({
|
||||
error: 'Invalid address',
|
||||
message: 'Please enter a valid street address (e.g., 123 Main Street)',
|
||||
});
|
||||
}
|
||||
|
||||
const limitReached = await checkPropertyLimit(reply, subscriptionId);
|
||||
if (!limitReached) return;
|
||||
|
||||
try {
|
||||
// Check for duplicate
|
||||
const existing = await prisma.watchlistItem.findFirst({
|
||||
where: { subscriptionId, type: 'address', value: body.address.trim() },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return reply.code(409).send({
|
||||
error: 'Duplicate property',
|
||||
message: 'This property is already being monitored',
|
||||
});
|
||||
}
|
||||
|
||||
const property = await prisma.watchlistItem.create({
|
||||
data: {
|
||||
subscriptionId,
|
||||
type: 'address',
|
||||
value: body.address.trim(),
|
||||
},
|
||||
});
|
||||
|
||||
// Trigger initial scan
|
||||
try {
|
||||
const { homeTitleScheduler } = await import('@shieldai/hometitle');
|
||||
if (homeTitleScheduler.isRunning()) {
|
||||
await homeTitleScheduler.runScan();
|
||||
}
|
||||
} catch (scanError) {
|
||||
console.error('[HomeTitle] Initial scan error:', scanError);
|
||||
}
|
||||
|
||||
return reply.code(201).send({
|
||||
property: {
|
||||
id: property.id,
|
||||
address: property.value,
|
||||
status: 'monitored',
|
||||
addedAt: property.createdAt,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to add property';
|
||||
return reply.code(422).send({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /hometitle/properties/:id - Remove property from monitoring
|
||||
fastify.delete('/properties/:id', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const subscriptionId = await requirePremiumTier(request, reply);
|
||||
if (!subscriptionId) return;
|
||||
|
||||
const id = (request.params as { id: string }).id;
|
||||
|
||||
try {
|
||||
const result = await prisma.watchlistItem.update({
|
||||
where: { id, subscriptionId },
|
||||
data: { isActive: false },
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
property: {
|
||||
id: result.id,
|
||||
address: result.value,
|
||||
status: 'removed',
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return reply.code(404).send({
|
||||
error: 'Property not found',
|
||||
message: 'The monitored property does not exist or is not owned by this user',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /hometitle/changes - Recent property changes
|
||||
fastify.get('/changes', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const subscriptionId = await requirePremiumTier(request, reply);
|
||||
if (!subscriptionId) return;
|
||||
|
||||
const query = request.query as { limit?: number };
|
||||
const limit = Math.min(query.limit ?? 20, 50);
|
||||
|
||||
try {
|
||||
const watchlistItemIds = (
|
||||
await prisma.watchlistItem.findMany({
|
||||
where: { subscriptionId, type: 'address', isActive: true },
|
||||
select: { id: true },
|
||||
})
|
||||
).map((item) => item.id);
|
||||
|
||||
const changes = await prisma.alert.findMany({
|
||||
where: {
|
||||
subscriptionId,
|
||||
watchlistItemId: { in: watchlistItemIds },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit,
|
||||
include: {
|
||||
watchlistItem: true,
|
||||
},
|
||||
});
|
||||
|
||||
const formattedChanges = changes.map((alert) => ({
|
||||
id: alert.id,
|
||||
propertyAddress: alert.watchlistItem?.value ?? 'Unknown',
|
||||
type: 'property_change',
|
||||
severity: alert.severity,
|
||||
title: alert.title,
|
||||
message: alert.message,
|
||||
isRead: alert.isRead,
|
||||
createdAt: alert.createdAt,
|
||||
}));
|
||||
|
||||
return reply.send({ changes: formattedChanges });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to fetch changes';
|
||||
return reply.code(500).send({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /hometitle/alerts - Recent property alerts
|
||||
fastify.get('/alerts', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const subscriptionId = await requirePremiumTier(request, reply);
|
||||
if (!subscriptionId) return;
|
||||
|
||||
const query = request.query as { limit?: number; severity?: string };
|
||||
const limit = Math.min(query.limit ?? 20, 50);
|
||||
|
||||
try {
|
||||
const watchlistItemIds = (
|
||||
await prisma.watchlistItem.findMany({
|
||||
where: { subscriptionId, type: 'address', isActive: true },
|
||||
select: { id: true },
|
||||
})
|
||||
).map((item) => item.id);
|
||||
|
||||
const whereClause: Record<string, unknown> = {
|
||||
subscriptionId,
|
||||
watchlistItemId: { in: watchlistItemIds },
|
||||
};
|
||||
|
||||
if (query.severity) {
|
||||
whereClause.severity = query.severity;
|
||||
}
|
||||
|
||||
const alerts = await prisma.alert.findMany({
|
||||
where: whereClause,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit,
|
||||
include: {
|
||||
watchlistItem: true,
|
||||
},
|
||||
});
|
||||
|
||||
const formattedAlerts = alerts.map((alert) => ({
|
||||
id: alert.id,
|
||||
propertyAddress: alert.watchlistItem?.value ?? 'Unknown',
|
||||
severity: alert.severity,
|
||||
title: alert.title,
|
||||
message: alert.message,
|
||||
isRead: alert.isRead,
|
||||
channel: alert.channel,
|
||||
createdAt: alert.createdAt,
|
||||
}));
|
||||
|
||||
return reply.send({ alerts: formattedAlerts });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to fetch alerts';
|
||||
return reply.code(500).send({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /hometitle/alerts/:id/read - Mark alert as read
|
||||
fastify.patch('/alerts/:id/read', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const subscriptionId = await requirePremiumTier(request, reply);
|
||||
if (!subscriptionId) return;
|
||||
|
||||
const id = (request.params as { id: string }).id;
|
||||
|
||||
try {
|
||||
const alert = await prisma.alert.update({
|
||||
where: { id, subscriptionId },
|
||||
data: { isRead: true, readAt: new Date() },
|
||||
});
|
||||
|
||||
return reply.send({ alert: { id: alert.id, isRead: alert.isRead } });
|
||||
} catch {
|
||||
return reply.code(404).send({ error: 'Alert not found' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /hometitle/scan - Trigger on-demand scan for all monitored properties
|
||||
fastify.post('/scan', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const subscriptionId = await requirePremiumTier(request, reply);
|
||||
if (!subscriptionId) return;
|
||||
|
||||
try {
|
||||
const { homeTitleScheduler } = await import('@shieldai/hometitle');
|
||||
const result = await homeTitleScheduler.runScan();
|
||||
|
||||
return reply.send({
|
||||
scan: {
|
||||
scanId: result.scanId,
|
||||
propertiesScanned: result.propertiesScanned,
|
||||
changesDetected: result.changesDetected,
|
||||
alertsCreated: result.alertsCreated,
|
||||
notificationsSent: result.notificationsSent,
|
||||
startedAt: result.startedAt,
|
||||
completedAt: result.completedAt,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Scan failed';
|
||||
return reply.code(500).send({ error: message });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -4,6 +4,11 @@ import { voiceprintRoutes } from './voiceprint.routes';
|
||||
import { spamshieldRoutes } from './spamshield.routes';
|
||||
import { darkwatchRoutes } from './darkwatch.routes';
|
||||
import { reportRoutes } from './report.routes';
|
||||
import { subscriptionRoutes } from './subscription.routes';
|
||||
import { deviceRoutes } from './device.routes';
|
||||
import { notificationRoutes } from './notifications.routes';
|
||||
import { hometitleRoutes } from './hometitle.routes';
|
||||
import { removebrokersRoutes } from './removebrokers.routes';
|
||||
|
||||
export async function routes(fastify: FastifyInstance) {
|
||||
// Authenticated routes group
|
||||
@@ -148,4 +153,42 @@ export async function routes(fastify: FastifyInstance) {
|
||||
},
|
||||
{ prefix: '/reports' }
|
||||
);
|
||||
|
||||
// Subscription routes
|
||||
fastify.register(
|
||||
async (subscriptionRouter) => {
|
||||
await subscriptionRoutes(subscriptionRouter);
|
||||
},
|
||||
{ prefix: '/billing' }
|
||||
);
|
||||
|
||||
// Device routes
|
||||
fastify.register(
|
||||
async (deviceRouter) => {
|
||||
await deviceRoutes(deviceRouter);
|
||||
},
|
||||
{ prefix: '/api/v1' }
|
||||
);
|
||||
|
||||
// Home Title service routes
|
||||
fastify.register(
|
||||
async (hometitleRouter) => {
|
||||
hometitleRouter.addHook('onRequest', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
await fastify.requireAuth(request as AuthRequest);
|
||||
});
|
||||
await hometitleRoutes(hometitleRouter);
|
||||
},
|
||||
{ prefix: '/hometitle' }
|
||||
);
|
||||
|
||||
// Info Broker Removal service routes
|
||||
fastify.register(
|
||||
async (removebrokersRouter) => {
|
||||
removebrokersRouter.addHook('onRequest', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
await fastify.requireAuth(request as AuthRequest);
|
||||
});
|
||||
await removebrokersRoutes(removebrokersRouter);
|
||||
},
|
||||
{ prefix: '/removebrokers' }
|
||||
);
|
||||
}
|
||||
|
||||
383
packages/api/src/routes/removebrokers.routes.ts
Normal file
383
packages/api/src/routes/removebrokers.routes.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { prisma } from '@shieldai/db';
|
||||
import { RemovalStatus, Severity, AlertCategory, EntityTypes } from '@shieldai/types';
|
||||
import type { RemovalStatus as PrismaRemovalStatus } from '@shieldai/db';
|
||||
import {
|
||||
removeBrokersService,
|
||||
removeBrokersScheduler,
|
||||
brokerAlertPipeline,
|
||||
type PersonalInfo,
|
||||
} from '@shieldai/removebrokers';
|
||||
import { AuthRequest } from '../middleware/auth.middleware';
|
||||
|
||||
const REMOVAL_REQUEST_LIMITS: Record<string, number> = {
|
||||
basic: 5,
|
||||
plus: 20,
|
||||
premium: 999,
|
||||
};
|
||||
|
||||
async function getSubscription(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
): Promise<{ subscriptionId: string; tier: string } | null> {
|
||||
const authReq = request as AuthRequest;
|
||||
const userId = authReq.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
await reply.code(401).send({ error: 'User not authenticated' });
|
||||
return null;
|
||||
}
|
||||
|
||||
const subscription = await prisma.subscription.findFirst({
|
||||
where: { userId, status: 'active' },
|
||||
select: { id: true, tier: true },
|
||||
});
|
||||
|
||||
if (!subscription) {
|
||||
await reply.code(402).send({
|
||||
error: 'Subscription required',
|
||||
message: 'An active subscription is required for data broker removal',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
return { subscriptionId: subscription.id, tier: subscription.tier };
|
||||
}
|
||||
|
||||
export async function removebrokersRoutes(fastify: FastifyInstance) {
|
||||
// GET /removebrokers/brokers - List available data brokers
|
||||
fastify.get('/brokers', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const authReq = request as AuthRequest;
|
||||
if (!authReq.user?.id) {
|
||||
return reply.code(401).send({ error: 'User not authenticated' });
|
||||
}
|
||||
|
||||
const query = request.query as { category?: string };
|
||||
let brokers = await removeBrokersService.getAvailableBrokers();
|
||||
|
||||
if (query.category) {
|
||||
brokers = brokers.filter((b) => b.category === query.category);
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
brokers: brokers.map((b) => ({
|
||||
id: b.id,
|
||||
name: b.name,
|
||||
domain: b.domain,
|
||||
category: b.category,
|
||||
removalMethod: b.removalMethod,
|
||||
requiresAccount: b.requiresAccount,
|
||||
requiresVerification: b.requiresVerification,
|
||||
estimatedDays: b.estimatedDays,
|
||||
removalUrl: b.removalUrl,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
// GET /removebrokers/status - Get removal request status for user
|
||||
fastify.get('/status', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const sub = await getSubscription(request, reply);
|
||||
if (!sub) return;
|
||||
|
||||
try {
|
||||
const status = await removeBrokersService.getRemovalStatus(sub.subscriptionId);
|
||||
|
||||
const total = status.length;
|
||||
const pending = status.filter((s) => s.status === RemovalStatus.PENDING).length;
|
||||
const submitted = status.filter((s) => s.status === RemovalStatus.SUBMITTED).length;
|
||||
const completed = status.filter((s) => s.status === RemovalStatus.COMPLETED).length;
|
||||
const failed = status.filter((s) => s.status === RemovalStatus.FAILED).length;
|
||||
|
||||
const limit = REMOVAL_REQUEST_LIMITS[sub.tier] ?? 5;
|
||||
const remaining = Math.max(0, limit - total);
|
||||
|
||||
return reply.send({
|
||||
stats: { total, pending, submitted, completed, failed },
|
||||
limit,
|
||||
remaining,
|
||||
tier: sub.tier,
|
||||
requests: status,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to fetch status';
|
||||
return reply.code(500).send({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /removebrokers/scan - Scan for personal listings across brokers
|
||||
fastify.post('/scan', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const sub = await getSubscription(request, reply);
|
||||
if (!sub) return;
|
||||
|
||||
const body = request.body as { fullName?: string; email?: string; phone?: string; address?: string };
|
||||
|
||||
if (!body.fullName) {
|
||||
return reply.code(400).send({
|
||||
error: 'Invalid request',
|
||||
message: 'fullName is required for scanning',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: (request as AuthRequest).user!.id },
|
||||
});
|
||||
|
||||
const personalInfo: PersonalInfo = {
|
||||
fullName: body.fullName,
|
||||
email: body.email || user?.email,
|
||||
phone: body.phone,
|
||||
address: body.address
|
||||
? { street: body.address }
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const results = await removeBrokersService.scanForListings(
|
||||
sub.subscriptionId,
|
||||
personalInfo,
|
||||
);
|
||||
|
||||
const found = results.filter((r) => r.found);
|
||||
|
||||
for (const listing of found) {
|
||||
try {
|
||||
await brokerAlertPipeline.sendListingFoundAlert({
|
||||
userId: (request as AuthRequest).user!.id,
|
||||
brokerName: listing.brokerName,
|
||||
brokerId: listing.brokerId,
|
||||
category: AlertCategory.INFO_BROKER_LISTING,
|
||||
severity: Severity.MEDIUM,
|
||||
title: `Personal listing found on ${listing.brokerName}`,
|
||||
description: `Your personal information was found on ${listing.brokerName} (${listing.brokerId}). Consider submitting a removal request.`,
|
||||
entities: [
|
||||
{ type: EntityTypes.USER_ID, value: (request as AuthRequest).user!.id },
|
||||
],
|
||||
metadata: { url: listing.url },
|
||||
});
|
||||
} catch {
|
||||
// Alert failure is non-critical
|
||||
}
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
brokersScanned: results.length,
|
||||
listingsFound: found.length,
|
||||
results,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Scan failed';
|
||||
return reply.code(500).send({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /removebrokers/request - Create a new removal request
|
||||
fastify.post('/request', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const sub = await getSubscription(request, reply);
|
||||
if (!sub) return;
|
||||
|
||||
const body = request.body as {
|
||||
brokerId: string;
|
||||
fullName: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
address?: {
|
||||
street?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
zip?: string;
|
||||
};
|
||||
dob?: string;
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
if (!body.brokerId) {
|
||||
return reply.code(400).send({
|
||||
error: 'Invalid request',
|
||||
message: 'brokerId is required',
|
||||
});
|
||||
}
|
||||
|
||||
if (!body.fullName) {
|
||||
return reply.code(400).send({
|
||||
error: 'Invalid request',
|
||||
message: 'fullName is required',
|
||||
});
|
||||
}
|
||||
|
||||
const limit = REMOVAL_REQUEST_LIMITS[sub.tier] ?? 5;
|
||||
const currentCount = await prisma.removalRequest.count({
|
||||
where: { subscriptionId: sub.subscriptionId },
|
||||
});
|
||||
|
||||
if (currentCount >= limit) {
|
||||
return reply.code(400).send({
|
||||
error: 'Request limit reached',
|
||||
message: `You have reached the maximum of ${limit} removal requests for your ${sub.tier} tier.`,
|
||||
currentCount,
|
||||
limit,
|
||||
upgradeTo: 'plus',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const personalInfo: PersonalInfo = {
|
||||
fullName: body.fullName,
|
||||
email: body.email,
|
||||
phone: body.phone,
|
||||
address: body.address,
|
||||
dob: body.dob,
|
||||
};
|
||||
|
||||
const req = await removeBrokersService.createRemovalRequest(
|
||||
sub.subscriptionId,
|
||||
body.brokerId,
|
||||
personalInfo,
|
||||
body.notes,
|
||||
);
|
||||
|
||||
return reply.code(201).send({
|
||||
request: {
|
||||
id: req.id,
|
||||
brokerId: req.brokerId,
|
||||
status: req.status,
|
||||
method: req.method,
|
||||
createdAt: req.createdAt,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to create removal request';
|
||||
return reply.code(422).send({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /removebrokers/request/:id - Get specific removal request
|
||||
fastify.get('/request/:id', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const sub = await getSubscription(request, reply);
|
||||
if (!sub) return;
|
||||
|
||||
const id = (request.params as { id: string }).id;
|
||||
|
||||
try {
|
||||
const req = await prisma.removalRequest.findFirst({
|
||||
where: { id, subscriptionId: sub.subscriptionId },
|
||||
include: { broker: true },
|
||||
});
|
||||
|
||||
if (!req) {
|
||||
return reply.code(404).send({ error: 'Removal request not found' });
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
request: {
|
||||
id: req.id,
|
||||
brokerId: req.brokerId,
|
||||
brokerName: req.broker?.name || null,
|
||||
status: req.status,
|
||||
method: req.method,
|
||||
attempts: req.attempts,
|
||||
submittedAt: req.submittedAt,
|
||||
completedAt: req.completedAt,
|
||||
error: req.error,
|
||||
notes: req.notes,
|
||||
createdAt: req.createdAt,
|
||||
updatedAt: req.updatedAt,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to fetch request';
|
||||
return reply.code(500).send({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /removebrokers/request/:id - Cancel a removal request
|
||||
fastify.delete('/request/:id', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const sub = await getSubscription(request, reply);
|
||||
if (!sub) return;
|
||||
|
||||
const id = (request.params as { id: string }).id;
|
||||
|
||||
try {
|
||||
const req = await prisma.removalRequest.findFirst({
|
||||
where: { id, subscriptionId: sub.subscriptionId },
|
||||
});
|
||||
|
||||
if (!req) {
|
||||
return reply.code(404).send({ error: 'Removal request not found' });
|
||||
}
|
||||
|
||||
if (req.status === RemovalStatus.COMPLETED) {
|
||||
return reply.code(400).send({
|
||||
error: 'Cannot cancel',
|
||||
message: 'Cannot cancel a completed removal request',
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.removalRequest.update({
|
||||
where: { id },
|
||||
data: { status: RemovalStatus.CANCELLED as PrismaRemovalStatus },
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
request: {
|
||||
id: req.id,
|
||||
status: RemovalStatus.CANCELLED,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to cancel request';
|
||||
return reply.code(500).send({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /removebrokers/process - Trigger processing of pending removals (admin)
|
||||
fastify.post('/process', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const authReq = request as AuthRequest;
|
||||
if (!authReq.user?.id) {
|
||||
return reply.code(401).send({ error: 'User not authenticated' });
|
||||
}
|
||||
|
||||
if (authReq.user.role !== 'support') {
|
||||
return reply.code(403).send({ error: 'Support access required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await removeBrokersService.processPendingRequests();
|
||||
|
||||
return reply.send({
|
||||
processed: results.length,
|
||||
results,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Processing failed';
|
||||
return reply.code(500).send({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /removebrokers/verify/:id - Manually verify a removal
|
||||
fastify.post('/verify/:id', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const sub = await getSubscription(request, reply);
|
||||
if (!sub) return;
|
||||
|
||||
const id = (request.params as { id: string }).id;
|
||||
|
||||
try {
|
||||
const req = await prisma.removalRequest.findFirst({
|
||||
where: { id, subscriptionId: sub.subscriptionId },
|
||||
});
|
||||
|
||||
if (!req) {
|
||||
return reply.code(404).send({ error: 'Removal request not found' });
|
||||
}
|
||||
|
||||
const result = await removeBrokersService.verifyRemoval(id);
|
||||
|
||||
return reply.send({
|
||||
requestId: id,
|
||||
...result,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Verification failed';
|
||||
return reply.code(500).send({ error: message });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -169,4 +169,9 @@ export async function reportRoutes(fastify: FastifyInstance) {
|
||||
const createdIds = await reportService.scheduleAnnualReports();
|
||||
return reply.code(200).send({ scheduled: createdIds.length, reportIds: createdIds });
|
||||
});
|
||||
|
||||
fastify.post('/schedule/weekly-digest', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const createdIds = await reportService.scheduleWeeklyDigest();
|
||||
return reply.code(200).send({ scheduled: createdIds.length, reportIds: createdIds });
|
||||
});
|
||||
}
|
||||
|
||||
425
packages/api/src/routes/subscription.routes.ts
Normal file
425
packages/api/src/routes/subscription.routes.ts
Normal file
@@ -0,0 +1,425 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { BillingService } from '@shieldai/shared-billing/src/services/billing.service';
|
||||
import { SubscriptionService, customerService, webhookService } from '@shieldai/shared-billing/src/services/billing.services';
|
||||
import { SubscriptionTier, isValidReturnUrl } from '@shieldai/shared-billing/src/config/billing.config';
|
||||
import { AuthRequest } from './auth.middleware';
|
||||
|
||||
const billingService = BillingService.getInstance();
|
||||
const subscriptionService = new SubscriptionService();
|
||||
|
||||
export async function subscriptionRoutes(fastify: FastifyInstance) {
|
||||
// Get current user's subscription
|
||||
fastify.get(
|
||||
'/subscription',
|
||||
{
|
||||
preHandler: async (request, reply) => {
|
||||
await fastify.requireAuth(request as AuthRequest);
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const authReq = request as AuthRequest;
|
||||
|
||||
try {
|
||||
const subscription = await billingService.getUserSubscription(authReq.user!.id);
|
||||
|
||||
if (!subscription) {
|
||||
return reply.status(404).send({
|
||||
error: 'No active subscription found',
|
||||
message: 'Please create a subscription to access premium features',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
subscription: {
|
||||
id: subscription.id,
|
||||
status: subscription.status,
|
||||
currentPeriodStart: new Date(subscription.current_period_start * 1000).toISOString(),
|
||||
currentPeriodEnd: new Date(subscription.current_period_end * 1000).toISOString(),
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
created: new Date(subscription.created * 1000).toISOString(),
|
||||
},
|
||||
customer: {
|
||||
id: subscription.customer as string,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
reply.status(500).send({
|
||||
error: 'Failed to fetch subscription',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Create a new subscription (for mobile app in-app purchases)
|
||||
fastify.post(
|
||||
'/subscription/create',
|
||||
{
|
||||
preHandler: async (request, reply) => {
|
||||
await fastify.requireAuth(request as AuthRequest);
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const authReq = request as AuthRequest;
|
||||
const { tier, customerId } = request.body as { tier: SubscriptionTier; customerId: string };
|
||||
|
||||
if (!authReq.user?.id) {
|
||||
return reply.status(401).send({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
if (!tier || !customerId) {
|
||||
return reply.status(400).send({
|
||||
error: 'Missing required fields',
|
||||
required: ['tier', 'customerId'],
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await billingService.createSubscription(
|
||||
authReq.user.id,
|
||||
tier,
|
||||
customerId
|
||||
);
|
||||
|
||||
return {
|
||||
subscription: {
|
||||
id: result.subscription.id,
|
||||
status: result.subscription.status,
|
||||
currentPeriodStart: new Date(result.subscription.current_period_start * 1000).toISOString(),
|
||||
currentPeriodEnd: new Date(result.subscription.current_period_end * 1000).toISOString(),
|
||||
},
|
||||
customer: {
|
||||
id: result.customer.id,
|
||||
email: result.customer.email,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
reply.status(500).send({
|
||||
error: 'Failed to create subscription',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Update subscription tier
|
||||
fastify.put(
|
||||
'/subscription/:subscriptionId/tier',
|
||||
{
|
||||
preHandler: async (request, reply) => {
|
||||
await fastify.requireAuth(request as AuthRequest);
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const authReq = request as AuthRequest;
|
||||
const { subscriptionId } = request.params as { subscriptionId: string };
|
||||
const { tier } = request.body as { tier: SubscriptionTier };
|
||||
|
||||
if (!authReq.user?.id) {
|
||||
return reply.status(401).send({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
if (!tier) {
|
||||
return reply.status(400).send({
|
||||
error: 'Missing required field',
|
||||
required: ['tier'],
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await billingService.updateSubscription(
|
||||
subscriptionId,
|
||||
authReq.user.id,
|
||||
tier
|
||||
);
|
||||
|
||||
return {
|
||||
subscription: {
|
||||
id: updated.id,
|
||||
status: updated.status,
|
||||
currentPeriodStart: new Date(updated.current_period_start * 1000).toISOString(),
|
||||
currentPeriodEnd: new Date(updated.current_period_end * 1000).toISOString(),
|
||||
},
|
||||
message: 'Subscription updated successfully',
|
||||
};
|
||||
} catch (error) {
|
||||
reply.status(500).send({
|
||||
error: 'Failed to update subscription',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Cancel subscription
|
||||
fastify.delete(
|
||||
'/subscription/:subscriptionId',
|
||||
{
|
||||
preHandler: async (request, reply) => {
|
||||
await fastify.requireAuth(request as AuthRequest);
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const authReq = request as AuthRequest;
|
||||
const { subscriptionId } = request.params as { subscriptionId: string };
|
||||
const { cancelAtPeriodEnd } = request.body as { cancelAtPeriodEnd?: boolean };
|
||||
|
||||
if (!authReq.user?.id) {
|
||||
return reply.status(401).send({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const cancelled = await billingService.cancelSubscription(
|
||||
subscriptionId,
|
||||
authReq.user.id,
|
||||
cancelAtPeriodEnd ?? true
|
||||
);
|
||||
|
||||
return {
|
||||
subscription: {
|
||||
id: cancelled.id,
|
||||
status: cancelled.status,
|
||||
cancelAtPeriodEnd: cancelled.cancel_at_period_end,
|
||||
canceledAt: cancelled.canceled_at ? new Date(cancelled.canceled_at * 1000).toISOString() : null,
|
||||
},
|
||||
message: cancelAtPeriodEnd
|
||||
? 'Subscription will cancel at the end of the billing period'
|
||||
: 'Subscription cancelled immediately',
|
||||
};
|
||||
} catch (error) {
|
||||
reply.status(500).send({
|
||||
error: 'Failed to cancel subscription',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Create customer portal session
|
||||
fastify.post(
|
||||
'/customer/portal',
|
||||
{
|
||||
preHandler: async (request, reply) => {
|
||||
await fastify.requireAuth(request as AuthRequest);
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const authReq = request as AuthRequest;
|
||||
const { customerId, returnUrl } = request.body as { customerId: string; returnUrl: string };
|
||||
|
||||
if (!authReq.user?.id) {
|
||||
return reply.status(401).send({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
if (!customerId || !returnUrl) {
|
||||
return reply.status(400).send({
|
||||
error: 'Missing required fields',
|
||||
required: ['customerId', 'returnUrl'],
|
||||
});
|
||||
}
|
||||
|
||||
if (!isValidReturnUrl(returnUrl)) {
|
||||
return reply.status(400).send({
|
||||
error: 'Invalid return URL',
|
||||
message: 'returnUrl must be from an allowed origin',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const portalSession = await billingService.createCustomerPortalSession(
|
||||
customerId,
|
||||
returnUrl
|
||||
);
|
||||
|
||||
return {
|
||||
url: portalSession.url,
|
||||
expiresAt: new Date(portalSession.expires_at * 1000).toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
reply.status(500).send({
|
||||
error: 'Failed to create portal session',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Create customer
|
||||
fastify.post(
|
||||
'/customer',
|
||||
{
|
||||
preHandler: async (request, reply) => {
|
||||
await fastify.requireAuth(request as AuthRequest);
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const authReq = request as AuthRequest;
|
||||
const { email, name } = request.body as { email: string; name?: string };
|
||||
|
||||
if (!authReq.user?.id) {
|
||||
return reply.status(401).send({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
if (!email) {
|
||||
return reply.status(400).send({
|
||||
error: 'Email is required',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const customer = await billingService.createCustomer(email, authReq.user.id);
|
||||
|
||||
return {
|
||||
customer: {
|
||||
id: customer.id,
|
||||
email: customer.email,
|
||||
name: customer.name,
|
||||
metadata: customer.metadata,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
reply.status(500).send({
|
||||
error: 'Failed to create customer',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get user tier
|
||||
fastify.get(
|
||||
'/user/tier',
|
||||
{
|
||||
preHandler: async (request, reply) => {
|
||||
await fastify.requireAuth(request as AuthRequest);
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const authReq = request as AuthRequest;
|
||||
|
||||
if (!authReq.user?.id) {
|
||||
return reply.status(401).send({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const tier = await billingService.getUserTier(authReq.user.id);
|
||||
|
||||
if (!tier) {
|
||||
return {
|
||||
tier: 'free' as SubscriptionTier,
|
||||
limits: await billingService.getTierLimits('free'),
|
||||
};
|
||||
}
|
||||
|
||||
const limits = await billingService.getTierLimits(tier);
|
||||
|
||||
return {
|
||||
tier,
|
||||
limits,
|
||||
};
|
||||
} catch (error) {
|
||||
reply.status(500).send({
|
||||
error: 'Failed to fetch user tier',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get invoice history
|
||||
fastify.get(
|
||||
'/invoices',
|
||||
{
|
||||
preHandler: async (request, reply) => {
|
||||
await fastify.requireAuth(request as AuthRequest);
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const authReq = request as AuthRequest;
|
||||
const { customerId } = request.query as { customerId?: string };
|
||||
|
||||
if (!authReq.user?.id) {
|
||||
return reply.status(401).send({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
if (!customerId) {
|
||||
return reply.status(400).send({
|
||||
error: 'customerId is required',
|
||||
});
|
||||
}
|
||||
|
||||
// Verify the customer belongs to the authenticated user (IDOR prevention)
|
||||
try {
|
||||
await billingService.verifyCustomerOwnership(customerId, authReq.user.id);
|
||||
} catch {
|
||||
return reply.status(403).send({
|
||||
error: 'Forbidden',
|
||||
message: 'You do not have access to this customer',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const invoices = await billingService.getInvoiceHistory(customerId);
|
||||
|
||||
return {
|
||||
invoices: invoices.data.map((invoice) => ({
|
||||
id: invoice.id,
|
||||
amountDue: invoice.amount_due,
|
||||
amountPaid: invoice.amount_paid,
|
||||
status: invoice.status,
|
||||
created: new Date(invoice.created * 1000).toISOString(),
|
||||
hostedInvoiceUrl: invoice.hosted_invoice_url,
|
||||
})),
|
||||
hasMore: invoices.has_more,
|
||||
};
|
||||
} catch (error) {
|
||||
reply.status(500).send({
|
||||
error: 'Failed to fetch invoice history',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Webhook handler (public endpoint)
|
||||
fastify.post(
|
||||
'/webhooks/stripe',
|
||||
{
|
||||
// Skip authentication for webhooks
|
||||
preHandler: async (request, reply) => {
|
||||
// Don't require auth for webhooks
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const sig = request.headers['stripe-signature'] as string;
|
||||
|
||||
if (!sig) {
|
||||
return reply.status(400).send({
|
||||
error: 'Missing Stripe signature',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const event = await billingService.handleWebhook(sig, request.rawBody as Buffer);
|
||||
|
||||
if (!event) {
|
||||
return reply.status(200).send({
|
||||
received: true,
|
||||
message: 'Event already processed',
|
||||
});
|
||||
}
|
||||
|
||||
// Handle different event types using webhook service
|
||||
await webhookService.handleWebhook(event);
|
||||
|
||||
return { received: true };
|
||||
} catch (error) {
|
||||
console.error('Webhook error:', error);
|
||||
return reply.status(400).send({
|
||||
error: 'Webhook handler failed',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import Fastify from "fastify";
|
||||
import cors from "@fastify/cors";
|
||||
import helmet from "@fastify/helmet";
|
||||
import sensible from "@fastify/sensible";
|
||||
import rawBody from "fastify-raw-body";
|
||||
import { extractOrGenerateRequestId } from "@shieldai/types";
|
||||
import { authMiddleware } from "./middleware/auth.middleware";
|
||||
import { errorHandlingMiddleware } from "./middleware/error-handling.middleware";
|
||||
@@ -16,8 +17,13 @@ import { extensionRoutes } from "./routes/extension.routes";
|
||||
import { waitlistRoutes } from "./routes/waitlist.routes";
|
||||
import { blogRoutes } from "./routes/blog.routes";
|
||||
import { blogAdminRoutes } from "./routes/blog-admin.routes";
|
||||
import { routes } from "./routes";
|
||||
import { captureSentryError } from "@shieldai/monitoring";
|
||||
import { getCorsOrigins } from "./config/api.config";
|
||||
import fastifySwagger from "@fastify/swagger";
|
||||
import fastifySwaggerUi from "@fastify/swagger-ui";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
@@ -30,6 +36,7 @@ async function bootstrap() {
|
||||
await app.register(cors, { origin: corsOrigins });
|
||||
await app.register(helmet);
|
||||
await app.register(sensible);
|
||||
await app.register(rawBody, { runFirst: true });
|
||||
|
||||
// Register auth middleware to populate request.user
|
||||
await app.register(authMiddleware);
|
||||
@@ -52,16 +59,54 @@ async function bootstrap() {
|
||||
request.headers["x-request-id"] = requestId;
|
||||
});
|
||||
|
||||
await app.register(darkwatchRoutes);
|
||||
await app.register(voiceprintRoutes);
|
||||
await app.register(correlationRoutes);
|
||||
await app.register(extensionRoutes, { prefix: '/extension' });
|
||||
await app.register(waitlistRoutes);
|
||||
await app.register(blogRoutes, { prefix: '/blog' });
|
||||
await app.register(blogAdminRoutes);
|
||||
await app.register(routes);
|
||||
|
||||
app.get("/health", async () => ({ status: "ok", timestamp: new Date().toISOString() }));
|
||||
|
||||
// Swagger/OpenAPI documentation
|
||||
const openapiSpec = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, "openapi", "spec.json"), "utf-8"),
|
||||
) as Record<string, unknown>;
|
||||
|
||||
const swaggerDefinition: Record<string, unknown> = {
|
||||
openapi: "3.0.3",
|
||||
info: {
|
||||
title: "ShieldAI API",
|
||||
description:
|
||||
"ShieldAI API documentation — reverse-engineer endpoints and run contract tests",
|
||||
version: "1.0.0",
|
||||
},
|
||||
servers: openapiSpec.servers,
|
||||
paths: openapiSpec.paths,
|
||||
components: openapiSpec.components,
|
||||
security: openapiSpec.security,
|
||||
tags: openapiSpec.tags,
|
||||
};
|
||||
|
||||
await app.register(fastifySwagger, {
|
||||
openapi: swaggerDefinition,
|
||||
});
|
||||
|
||||
await app.register(fastifySwaggerUi, {
|
||||
routePrefix: "/docs",
|
||||
uiConfig: {
|
||||
docExpansion: "list",
|
||||
},
|
||||
staticCSP: true,
|
||||
theme: {
|
||||
js: [
|
||||
{
|
||||
filename: "custom.js",
|
||||
content: `
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
document.querySelector('link[rel="icon"]')?.remove();
|
||||
});
|
||||
`,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await app.listen({ port: parseInt(process.env.PORT || "3000", 10), host: "0.0.0.0" });
|
||||
app.log.info(`Server listening on port ${process.env.PORT || 3000}`);
|
||||
|
||||
@@ -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");
|
||||
@@ -25,6 +25,7 @@ model User {
|
||||
// Relationships
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
deviceTokens DeviceToken[]
|
||||
familyGroups FamilyGroupMember[]
|
||||
familyGroupOwned FamilyGroup[] @relation("FamilyGroupOwner")
|
||||
subscriptions Subscription[]
|
||||
@@ -107,6 +108,42 @@ model Session {
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model DeviceToken {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
deviceType DeviceType
|
||||
token String @unique
|
||||
platform Platform
|
||||
appName String?
|
||||
appVersion String?
|
||||
osVersion String?
|
||||
model String?
|
||||
isActive Boolean @default(true)
|
||||
lastUsedAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([userId])
|
||||
@@index([deviceType])
|
||||
@@index([platform])
|
||||
@@index([isActive])
|
||||
}
|
||||
|
||||
enum DeviceType {
|
||||
mobile
|
||||
web
|
||||
desktop
|
||||
}
|
||||
|
||||
enum Platform {
|
||||
ios
|
||||
android
|
||||
web
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Family & Subscription Models
|
||||
// ============================================
|
||||
@@ -167,6 +204,9 @@ model Subscription {
|
||||
watchlistItems WatchlistItem[]
|
||||
exposures Exposure[]
|
||||
alerts Alert[]
|
||||
propertyWatchlistItems PropertyWatchlistItem[]
|
||||
removalRequests RemovalRequest[]
|
||||
brokerListings BrokerListing[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -506,6 +546,8 @@ enum AlertSource {
|
||||
SPAMSHIELD
|
||||
VOICEPRINT
|
||||
CALL_ANALYSIS
|
||||
HOME_TITLE
|
||||
INFO_BROKER
|
||||
}
|
||||
|
||||
enum AlertCategory {
|
||||
@@ -517,6 +559,9 @@ enum AlertCategory {
|
||||
CALL_ANOMALY
|
||||
CALL_QUALITY
|
||||
CALL_EVENT
|
||||
HOME_TITLE
|
||||
INFO_BROKER_LISTING
|
||||
INFO_BROKER_REMOVAL
|
||||
}
|
||||
|
||||
enum NormalizedAlertSeverity {
|
||||
@@ -531,6 +576,7 @@ enum NormalizedAlertSeverity {
|
||||
enum CorrelationStatus {
|
||||
ACTIVE
|
||||
RESOLVED
|
||||
FALSE_POSITIVE
|
||||
}
|
||||
|
||||
model NormalizedAlert {
|
||||
@@ -588,6 +634,7 @@ model CorrelationGroup {
|
||||
enum ReportType {
|
||||
MONTHLY_PLUS
|
||||
ANNUAL_PREMIUM
|
||||
WEEKLY_DIGEST
|
||||
}
|
||||
|
||||
enum ReportStatus {
|
||||
@@ -676,3 +723,196 @@ model BlogPost {
|
||||
@@index([published, publishedAt])
|
||||
@@index([tags])
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Home Title Service Models
|
||||
// ============================================
|
||||
|
||||
enum PropertyChangeType {
|
||||
tax_change
|
||||
deed_change
|
||||
ownership_transfer
|
||||
lien_filing
|
||||
metadata_change
|
||||
}
|
||||
|
||||
enum PropertyChangeSeverity {
|
||||
info
|
||||
warning
|
||||
critical
|
||||
}
|
||||
|
||||
model PropertyWatchlistItem {
|
||||
id String @id @default(uuid())
|
||||
subscriptionId String
|
||||
address String
|
||||
parcelId String?
|
||||
ownerName String?
|
||||
streetAddress String
|
||||
city String? @default("")
|
||||
state String? @default("")
|
||||
zipCode String? @default("")
|
||||
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
|
||||
capturedAt DateTime
|
||||
ownerName String
|
||||
address Json // { streetNumber, streetName, streetType, unit, city, state, zip, latitude?, longitude? }
|
||||
deedDate String?
|
||||
taxId String?
|
||||
propertyType String @default("residential")
|
||||
taxAmount Float?
|
||||
lienCount Int @default(0)
|
||||
|
||||
propertyWatchlistItem PropertyWatchlistItem @relation(fields: [propertyWatchlistItemId], references: [id], onDelete: Cascade)
|
||||
changes PropertyChange[] @relation("SnapshotChanges")
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([propertyWatchlistItemId])
|
||||
@@index([subscriptionId])
|
||||
@@index([capturedAt])
|
||||
}
|
||||
|
||||
model PropertyChange {
|
||||
id String @id @default(uuid())
|
||||
propertyWatchlistItemId String
|
||||
snapshotId String?
|
||||
changeType PropertyChangeType
|
||||
severity PropertyChangeSeverity @default(info)
|
||||
details Json?
|
||||
detectedAt DateTime @default(now())
|
||||
|
||||
propertyWatchlistItem PropertyWatchlistItem @relation(fields: [propertyWatchlistItemId], references: [id], onDelete: Cascade)
|
||||
snapshot PropertySnapshot? @relation("SnapshotChanges", fields: [snapshotId], references: [id])
|
||||
|
||||
@@index([propertyWatchlistItemId])
|
||||
@@index([snapshotId])
|
||||
@@index([changeType])
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Info Broker Removal Models
|
||||
// ============================================
|
||||
|
||||
enum BrokerCategory {
|
||||
PEOPLE_SEARCH
|
||||
BACKGROUND_CHECK
|
||||
PUBLIC_RECORDS
|
||||
REVERSE_LOOKUP
|
||||
SOCIAL_MEDIA
|
||||
}
|
||||
|
||||
enum RemovalMethod {
|
||||
AUTOMATED
|
||||
MANUAL_FORM
|
||||
EMAIL
|
||||
PHONE
|
||||
MAIL
|
||||
NONE
|
||||
}
|
||||
|
||||
enum RemovalStatus {
|
||||
PENDING
|
||||
SUBMITTED
|
||||
IN_PROGRESS
|
||||
COMPLETED
|
||||
FAILED
|
||||
REJECTED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
model InfoBroker {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
domain String @unique
|
||||
category BrokerCategory
|
||||
removalMethod RemovalMethod
|
||||
removalUrl String?
|
||||
requiresAccount Boolean @default(false)
|
||||
requiresVerification Boolean @default(false)
|
||||
estimatedDays Int @default(14)
|
||||
isActive Boolean @default(true)
|
||||
|
||||
removalRequests RemovalRequest[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
|
||||
@@index([category])
|
||||
@@index([isActive])
|
||||
@@index([removalMethod])
|
||||
}
|
||||
|
||||
model RemovalRequest {
|
||||
id String @id @default(uuid())
|
||||
subscriptionId String
|
||||
brokerId String
|
||||
status RemovalStatus @default(PENDING)
|
||||
personalInfo Json // { fullName, email?, phone?, address?, dob? }
|
||||
method RemovalMethod
|
||||
attempts Int @default(0)
|
||||
nextRetryAt DateTime?
|
||||
submittedAt DateTime?
|
||||
completedAt DateTime?
|
||||
error String?
|
||||
notes String?
|
||||
metadata Json? // Broker response data, tracking info
|
||||
|
||||
broker InfoBroker @relation(fields: [brokerId], references: [id])
|
||||
subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
|
||||
brokerListings BrokerListing[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
|
||||
@@index([subscriptionId])
|
||||
@@index([brokerId])
|
||||
@@index([status])
|
||||
@@index([submittedAt])
|
||||
@@index([subscriptionId, status])
|
||||
}
|
||||
|
||||
model BrokerListing {
|
||||
id String @id @default(uuid())
|
||||
subscriptionId String
|
||||
brokerId String
|
||||
removalRequestId String?
|
||||
url String
|
||||
dataFound Json // Fields found on the listing
|
||||
screenshotUrl String?
|
||||
isRemoved Boolean @default(false)
|
||||
removedAt DateTime?
|
||||
|
||||
removalRequest RemovalRequest? @relation(fields: [removalRequestId], references: [id])
|
||||
subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
|
||||
|
||||
scannedAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
|
||||
@@index([subscriptionId])
|
||||
@@index([brokerId])
|
||||
@@index([removalRequestId])
|
||||
@@index([isRemoved])
|
||||
@@index([subscriptionId, isRemoved])
|
||||
}
|
||||
|
||||
@@ -44,6 +44,9 @@ export type {
|
||||
FamilyGroupMember,
|
||||
Subscription,
|
||||
WatchlistItem,
|
||||
PropertyWatchlistItem,
|
||||
PropertySnapshot,
|
||||
PropertyChange,
|
||||
Exposure,
|
||||
Alert,
|
||||
VoiceEnrollment,
|
||||
@@ -57,11 +60,16 @@ export type {
|
||||
SecurityReport,
|
||||
WaitlistEntry,
|
||||
BlogPost,
|
||||
InfoBroker,
|
||||
RemovalRequest,
|
||||
BrokerListing,
|
||||
UserRole,
|
||||
FamilyMemberRole,
|
||||
SubscriptionTier,
|
||||
SubscriptionStatus,
|
||||
WatchlistType,
|
||||
PropertyChangeType,
|
||||
PropertyChangeSeverity,
|
||||
ExposureSource,
|
||||
ExposureSeverity,
|
||||
AlertType,
|
||||
@@ -72,6 +80,9 @@ export type {
|
||||
RuleAction,
|
||||
ReportType,
|
||||
ReportStatus,
|
||||
RemovalStatus,
|
||||
RemovalMethod,
|
||||
BrokerCategory,
|
||||
AnalysisType,
|
||||
AnalysisJobStatus,
|
||||
DetectionVerdict,
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
"rootDir": "./src",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "Bundler"
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
||||
@@ -75,16 +75,42 @@ async function processReportGeneration(
|
||||
|
||||
const userName = user?.name || notifyEmail.split('@')[0];
|
||||
|
||||
await emailService.sendWithTemplate(notifyEmail, {
|
||||
templateId: 'report_ready',
|
||||
variables: {
|
||||
name: userName,
|
||||
report_title: report.title,
|
||||
report_summary: report.summary || 'Your protection report contains detailed statistics and recommendations.',
|
||||
report_url: `${dashboardUrl}/reports/${report.id}`,
|
||||
pdf_url: report.pdfUrl || `${dashboardUrl}/api/v1/reports/${report.id}/pdf`,
|
||||
},
|
||||
});
|
||||
const templateId = report.reportType === 'WEEKLY_DIGEST' ? 'weekly_digest' : 'report_ready';
|
||||
|
||||
if (report.reportType === 'WEEKLY_DIGEST' && report.dataPayload) {
|
||||
const payload = typeof report.dataPayload === 'string' ? JSON.parse(report.dataPayload) : report.dataPayload;
|
||||
|
||||
await emailService.sendWithTemplate(notifyEmail, {
|
||||
templateId: 'weekly_digest',
|
||||
variables: {
|
||||
name: userName,
|
||||
period_start: new Date(report.periodStart).toLocaleDateString('en-US', { month: 'long', day: 'numeric' }),
|
||||
period_end: new Date(report.periodEnd).toLocaleDateString('en-US', { month: 'long', day: 'numeric' }),
|
||||
protection_score: payload.protectionScore || 0,
|
||||
new_exposures: payload.exposureSummary?.newExposures || 0,
|
||||
critical_exposures: payload.exposureSummary?.criticalExposures || 0,
|
||||
spam_events_blocked: payload.spamStats?.totalSpamEvents || 0,
|
||||
calls_blocked: payload.spamStats?.callsBlocked || 0,
|
||||
texts_blocked: payload.spamStats?.textsBlocked || 0,
|
||||
voice_threats: payload.voiceStats?.threatsDetected || 0,
|
||||
enrollments_active: payload.voiceStats?.enrollmentsActive || 0,
|
||||
properties_monitored: payload.homeTitleStats?.propertiesMonitored || 0,
|
||||
changes_detected: payload.homeTitleStats?.changesDetected || 0,
|
||||
report_url: `${dashboardUrl}/reports/${report.id}`,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await emailService.sendWithTemplate(notifyEmail, {
|
||||
templateId: 'report_ready',
|
||||
variables: {
|
||||
name: userName,
|
||||
report_title: report.title,
|
||||
report_summary: report.summary || 'Your protection report contains detailed statistics and recommendations.',
|
||||
report_url: `${dashboardUrl}/reports/${report.id}`,
|
||||
pdf_url: report.pdfUrl || `${dashboardUrl}/api/v1/reports/${report.id}/pdf`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.securityReport.update({
|
||||
where: { id: report.id },
|
||||
@@ -245,6 +271,13 @@ export async function scheduleMonthlyReportTrigger() {
|
||||
});
|
||||
}
|
||||
|
||||
export async function scheduleWeeklyDigestTrigger() {
|
||||
return reportSchedulerQueue.add('trigger-weekly-digest', {}, {
|
||||
repeat: { pattern: '0 8 * * 1' },
|
||||
jobId: 'weekly-digest-trigger',
|
||||
});
|
||||
}
|
||||
|
||||
export async function scheduleAnnualReportTrigger() {
|
||||
return reportSchedulerQueue.add('trigger-annual-reports', {}, {
|
||||
repeat: { pattern: '0 0 1 1 *' },
|
||||
|
||||
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"]
|
||||
}
|
||||
20
packages/mobile/.gitignore
vendored
Normal file
20
packages/mobile/.gitignore
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
node_modules/
|
||||
.expo/
|
||||
.expo-shared/
|
||||
dist/
|
||||
ios/
|
||||
android/
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.mobileprovision
|
||||
*.orig.*
|
||||
*.pub
|
||||
.jscache
|
||||
*.log
|
||||
*.pid
|
||||
*.tgz
|
||||
*.npm
|
||||
*.lock
|
||||
.DS_Store
|
||||
.env
|
||||
29
packages/mobile/App.tsx
Normal file
29
packages/mobile/App.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { NavigationContainer } from '@react-navigation/native';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { AuthNavigator } from '@/navigation/AuthNavigator';
|
||||
import { MainTabNavigator } from '@/navigation/MainTabNavigator';
|
||||
import { usePushNotifications } from '@/hooks';
|
||||
import '@/services/api';
|
||||
|
||||
export default function App() {
|
||||
const { isAuthenticated } = useAuthStore();
|
||||
const { registerForPushNotifications } = usePushNotifications();
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
registerForPushNotifications();
|
||||
}
|
||||
}, [isAuthenticated, registerForPushNotifications]);
|
||||
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<NavigationContainer>
|
||||
<StatusBar style="light" />
|
||||
{isAuthenticated ? <MainTabNavigator /> : <AuthNavigator />}
|
||||
</NavigationContainer>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
66
packages/mobile/app.json
Normal file
66
packages/mobile/app.json
Normal file
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "ShieldAI",
|
||||
"slug": "shieldai",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"scheme": "shieldai",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"splash": {
|
||||
"image": "./assets/splash.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#0a0e1a"
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.frenocorp.shieldai",
|
||||
"infoPlist": {
|
||||
"NSFaceIDUsageDescription": "ShieldAI uses Face ID to securely access your account.",
|
||||
"NSCameraUsageDescription": "ShieldAI needs camera access for VoicePrint enrollment.",
|
||||
"NSMicrophoneUsageDescription": "ShieldAI needs microphone access for voice analysis."
|
||||
},
|
||||
"config": {
|
||||
"usesNonExemptEncryption": false
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#0a0e1a"
|
||||
},
|
||||
"package": "com.frenocorp.shieldai",
|
||||
"permissions": [
|
||||
"RECEIVE_BOOT_COMPLETED",
|
||||
"VIBRATE",
|
||||
"android.permission.CAMERA",
|
||||
"android.permission.RECORD_AUDIO"
|
||||
]
|
||||
},
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
"output": "static",
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-secure-store",
|
||||
"expo-local-authentication",
|
||||
[
|
||||
"expo-notifications",
|
||||
{
|
||||
"icon": "./assets/notification-icon.png",
|
||||
"color": "#4f8cff",
|
||||
"sounds": []
|
||||
}
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
},
|
||||
"extra": {
|
||||
"eas": {
|
||||
"projectId": "shieldai-project-id"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
packages/mobile/assets/adaptive-icon.png
Normal file
BIN
packages/mobile/assets/adaptive-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
BIN
packages/mobile/assets/favicon.png
Normal file
BIN
packages/mobile/assets/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
BIN
packages/mobile/assets/icon.png
Normal file
BIN
packages/mobile/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
BIN
packages/mobile/assets/notification-icon.png
Normal file
BIN
packages/mobile/assets/notification-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
BIN
packages/mobile/assets/splash.png
Normal file
BIN
packages/mobile/assets/splash.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
67
packages/mobile/assets/store/README.md
Normal file
67
packages/mobile/assets/store/README.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# ShieldAI - App Store Metadata
|
||||
|
||||
## App Information
|
||||
- **App Name:** ShieldAI
|
||||
- **Subtitle:** Your Digital Identity Shield
|
||||
- **Description:** ShieldAI protects your digital identity in real-time. Monitor data breaches, block spam calls and texts, and verify voices with AI-powered protection. Get instant alerts when your personal information appears online, manage your watch list for new threats, and keep your family safe with VoicePrint verification.
|
||||
- **Keyword:** privacy,security,spam,identity,breach,protection,voice,ai
|
||||
- **Support URL:** https://shieldai.frenocorp.com/support
|
||||
- **Privacy URL:** https://shieldai.frenocorp.com/privacy
|
||||
- **Marketing URL:** https://shieldai.frenocorp.com
|
||||
|
||||
## iOS App Store Requirements
|
||||
|
||||
### Screenshots (Required)
|
||||
- iPhone 6.7" (1290x2796): 3-5 screenshots
|
||||
- iPhone 6.1" (1170x2532): 3-5 screenshots
|
||||
- iPad 10.9" (2048x2732): 3-5 screenshots (if tablet supported)
|
||||
- Format: PNG, no rounded corners, no device frame overlays
|
||||
|
||||
### App Icon
|
||||
- 1024x1024 PNG, no transparency, no rounded corners
|
||||
- Place at: `assets/store/ios/app-icon-1024.png`
|
||||
|
||||
### Preview Video (Optional)
|
||||
- 9:16 or 16:9, 20-90 seconds
|
||||
- MP4 format, under 100MB
|
||||
|
||||
## Google Play Store Requirements
|
||||
|
||||
### Screenshots (Required)
|
||||
- Phone (1080x1920 recommended): 2-8 screenshots
|
||||
- Tablet (1600x2560 recommended): 2-8 screenshots (if tablet supported)
|
||||
- Format: PNG or JPEG, under 8MB each
|
||||
|
||||
### App Icon
|
||||
- 512x512 PNG, 32-bit with transparency, 1MB or less
|
||||
- Place at: `assets/store/android/app-icon-512.png`
|
||||
|
||||
### Feature Graphic
|
||||
- 1024x500 PNG, 1MB or less, no transparency
|
||||
- Place at: `assets/store/android/feature-graphic.png`
|
||||
|
||||
### Video (Optional)
|
||||
- YouTube URL or uploaded video
|
||||
|
||||
## Tier-Based Feature Highlights for Screenshots
|
||||
|
||||
### Free Tier Screenshots
|
||||
1. Dashboard - Basic exposure summary
|
||||
2. SpamShield - Call/text spam detection
|
||||
3. Settings - Account and notifications
|
||||
|
||||
### Plus Tier Screenshots
|
||||
1. DarkWatch - Watch list and exposure feed
|
||||
2. Alert notification preview
|
||||
3. Real-time breach monitoring
|
||||
|
||||
### Premium Tier Screenshots
|
||||
1. VoicePrint - Family enrollment
|
||||
2. Voice analysis results
|
||||
3. Deepfake detection status
|
||||
|
||||
## Screenshot Notes
|
||||
- Screenshots should show the app in both light and dark mode
|
||||
- Highlight key differentiators: real-time alerts, AI voice verification, family protection
|
||||
- Use consistent device mockups across stores
|
||||
- Localization-ready: avoid hardcoded text in screenshot UI
|
||||
19
packages/mobile/assets/store/android/screenshots/README.md
Normal file
19
packages/mobile/assets/store/android/screenshots/README.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Android Screenshots
|
||||
|
||||
Place screenshots in this directory before submission.
|
||||
|
||||
## Required Sizes
|
||||
- `phone-1080x1920.png` - 1080x1920 (standard phone)
|
||||
- `tablet-1600x2560.png` - 1600x2560 (if tablet supported)
|
||||
|
||||
## Screens to Capture
|
||||
1. Dashboard - exposure summary cards
|
||||
2. DarkWatch - watch list with entities
|
||||
3. SpamShield - call/text history
|
||||
4. VoicePrint - family member enrollment
|
||||
5. Settings - tier and notifications
|
||||
|
||||
## Notes
|
||||
- Use Android emulator or connected device for captures
|
||||
- Minimum 2 screenshots required, up to 8 allowed
|
||||
- Landscape screenshots optional for productivity apps
|
||||
19
packages/mobile/assets/store/ios/screenshots/README.md
Normal file
19
packages/mobile/assets/store/ios/screenshots/README.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# iOS Screenshots
|
||||
|
||||
Place screenshots in this directory before submission.
|
||||
|
||||
## Required Sizes
|
||||
- `iphone-6.7in.png` - 1290x2796 (iPhone 14 Pro Max, 15 Pro Max)
|
||||
- `iphone-6.1in.png` - 1170x2532 (iPhone 14, 15)
|
||||
|
||||
## Screens to Capture
|
||||
1. Dashboard - exposure summary cards
|
||||
2. DarkWatch - watch list with entities
|
||||
3. SpamShield - call/text history
|
||||
4. VoicePrint - family member enrollment
|
||||
5. Settings - tier and notifications
|
||||
|
||||
## Notes
|
||||
- Use Xcode simulator or `xcrun simctl io booted screenshot` for captures
|
||||
- No device frame overlays in screenshots
|
||||
- Ensure dark mode and light mode are represented
|
||||
11
packages/mobile/assets/store/marketing/README.md
Normal file
11
packages/mobile/assets/store/marketing/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# Marketing Assets
|
||||
|
||||
Place marketing materials here.
|
||||
|
||||
## Required
|
||||
- `app-store-preview.png` - 1200x628 OG image for social sharing
|
||||
- `feature-graphic.png` - 1024x500 for Google Play feature graphic
|
||||
|
||||
## Optional
|
||||
- `promo-video.mp4` - 30s promo for app store video preview
|
||||
- `press-kit.zip` - Press kit with logos, screenshots, and brand guidelines
|
||||
9
packages/mobile/babel.config.js
Normal file
9
packages/mobile/babel.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
plugins: [
|
||||
'react-native-reanimated/plugin',
|
||||
],
|
||||
};
|
||||
};
|
||||
58
packages/mobile/eas.json
Normal file
58
packages/mobile/eas.json
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 10.0.0",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal",
|
||||
"ios": {
|
||||
"resourceClass": "m1-medium"
|
||||
},
|
||||
"android": {
|
||||
"buildType": "apk"
|
||||
},
|
||||
"env": {
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal",
|
||||
"ios": {
|
||||
"simulator": true,
|
||||
"resourceClass": "m1-medium"
|
||||
},
|
||||
"android": {
|
||||
"buildType": "apk"
|
||||
},
|
||||
"env": {
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"ios": {
|
||||
"resourceClass": "m1-medium"
|
||||
},
|
||||
"android": {
|
||||
"buildType": "app-bundle"
|
||||
},
|
||||
"env": {
|
||||
"NODE_ENV": "production"
|
||||
}
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {
|
||||
"ios": {
|
||||
"ascAppId": "shieldai-app-id",
|
||||
"appleId": "apple@frenocorp.com",
|
||||
"appleTeamId": "FRENOCORP-TEAM-ID"
|
||||
},
|
||||
"android": {
|
||||
"serviceAccountKeyPath": "./google-play-service-account.json",
|
||||
"track": "internal"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
8
packages/mobile/metro.config.js
Normal file
8
packages/mobile/metro.config.js
Normal file
@@ -0,0 +1,8 @@
|
||||
const { getDefaultConfig } = require('expo/metro-config');
|
||||
|
||||
const config = getDefaultConfig(__dirname);
|
||||
|
||||
config.resolver.sourceExts = ['js', 'jsx', 'ts', 'tsx', 'json'];
|
||||
config.watchFolders = [__dirname];
|
||||
|
||||
module.exports = config;
|
||||
@@ -1,22 +1,61 @@
|
||||
{
|
||||
"name": "mobile",
|
||||
"version": "0.1.0",
|
||||
"name": "@shieldai/mobile",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "node_modules/expo/AppEntry.js",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint src/"
|
||||
"dev": "expo start",
|
||||
"dev:ios": "expo run:ios",
|
||||
"dev:android": "expo run:android",
|
||||
"build": "expo build",
|
||||
"build:ios": "expo build:ios",
|
||||
"build:android": "expo build:android",
|
||||
"lint": "eslint src/",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "jest",
|
||||
"clean": "rm -rf node_modules .expo .expo-shared"
|
||||
},
|
||||
"dependencies": {
|
||||
"solid-js": "^1.8.14",
|
||||
"@shieldsai/shared-auth": "workspace:*",
|
||||
"@shieldsai/shared-ui": "workspace:*",
|
||||
"@shieldsai/shared-utils": "workspace:*"
|
||||
"@shieldai/mobile-api-client": "workspace:*",
|
||||
"@expo/vector-icons": "^14.0.0",
|
||||
"@react-native-async-storage/async-storage": "1.23.1",
|
||||
"@react-native-community/netinfo": "11.4.1",
|
||||
"@react-navigation/bottom-tabs": "^6.5.0",
|
||||
"@react-navigation/native": "^6.1.0",
|
||||
"@react-navigation/stack": "^6.3.0",
|
||||
"expo": "~51.0.0",
|
||||
"expo-av": "~14.0.0",
|
||||
|
||||
"expo-constants": "~16.0.0",
|
||||
"expo-crypto": "~13.0.0",
|
||||
"expo-device": "~6.0.0",
|
||||
"expo-file-system": "~17.0.0",
|
||||
"expo-image-picker": "~15.0.0",
|
||||
"expo-linking": "~6.3.0",
|
||||
"expo-local-authentication": "~14.0.0",
|
||||
"expo-notifications": "~0.28.0",
|
||||
"expo-secure-store": "~13.0.0",
|
||||
"expo-status-bar": "~1.12.0",
|
||||
"expo-updates": "~0.18.0",
|
||||
"react": "18.2.0",
|
||||
"react-native": "0.74.5",
|
||||
"react-native-gesture-handler": "~2.16.0",
|
||||
"react-native-reanimated": "~3.10.0",
|
||||
"react-native-safe-area-context": "4.10.5",
|
||||
"react-native-screens": "3.31.0",
|
||||
"react-native-svg": "15.2.0",
|
||||
"zustand": "^4.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.1.4",
|
||||
"@types/node": "^25.6.0"
|
||||
"@babel/core": "^7.24.0",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-test-renderer": "^18.0.0",
|
||||
"babel-preset-expo": "^11.0.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react": "^7.34.0",
|
||||
"eslint-plugin-react-native": "^4.1.0",
|
||||
"jest": "^29.7.0",
|
||||
"react-test-renderer": "18.2.0",
|
||||
"typescript": "^5.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
65
packages/mobile/src/components/Button.tsx
Normal file
65
packages/mobile/src/components/Button.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { COLORS, BORDER_RADIUS, FONT_SIZES } from '@/constants/theme';
|
||||
|
||||
interface ButtonProps {
|
||||
title: string;
|
||||
onPress: () => void;
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
export function Button({
|
||||
title,
|
||||
onPress,
|
||||
variant = 'primary',
|
||||
disabled = false,
|
||||
loading = false,
|
||||
fullWidth = false,
|
||||
}: ButtonProps) {
|
||||
const variantColors = {
|
||||
primary: { bg: COLORS.primary, text: '#fff' },
|
||||
secondary: { bg: COLORS.secondary, text: '#fff' },
|
||||
danger: { bg: COLORS.danger, text: '#fff' },
|
||||
ghost: { bg: 'transparent', text: COLORS.primary },
|
||||
};
|
||||
|
||||
const colors = variantColors[variant];
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.button,
|
||||
{ backgroundColor: colors.bg, opacity: disabled || loading ? 0.5 : 1 },
|
||||
fullWidth && styles.fullWidth,
|
||||
]}
|
||||
onPress={onPress}
|
||||
disabled={disabled || loading}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.text, { color: colors.text }]}>
|
||||
{loading ? '...' : title}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
button: {
|
||||
borderRadius: BORDER_RADIUS.md,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 24,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginVertical: 4,
|
||||
},
|
||||
fullWidth: {
|
||||
width: '100%',
|
||||
},
|
||||
text: {
|
||||
fontSize: FONT_SIZES.md,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
69
packages/mobile/src/components/Card.tsx
Normal file
69
packages/mobile/src/components/Card.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, View, ViewStyle } from 'react-native';
|
||||
import { COLORS, BORDER_RADIUS, FONT_SIZES, SPACING } from '@/constants/theme';
|
||||
|
||||
interface SectionHeaderProps {
|
||||
title: string;
|
||||
action?: string;
|
||||
onActionPress?: () => void;
|
||||
}
|
||||
|
||||
export function SectionHeader({ title, action, onActionPress }: SectionHeaderProps) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
{action && onActionPress && (
|
||||
<Text style={styles.action} onPress={onActionPress}>
|
||||
{action}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface CardProps {
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
export function Card({ title, children, style }: CardProps) {
|
||||
return (
|
||||
<View style={[styles.card, style]}>
|
||||
{title && <Text style={styles.cardTitle}>{title}</Text>}
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: SPACING.md,
|
||||
paddingVertical: SPACING.sm,
|
||||
},
|
||||
title: {
|
||||
color: COLORS.text,
|
||||
fontSize: FONT_SIZES.lg,
|
||||
fontWeight: '600',
|
||||
},
|
||||
action: {
|
||||
color: COLORS.primary,
|
||||
fontSize: FONT_SIZES.sm,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: COLORS.card,
|
||||
borderRadius: BORDER_RADIUS.lg,
|
||||
padding: SPACING.md,
|
||||
marginHorizontal: SPACING.md,
|
||||
marginVertical: SPACING.xs,
|
||||
},
|
||||
cardTitle: {
|
||||
color: COLORS.text,
|
||||
fontSize: FONT_SIZES.md,
|
||||
fontWeight: '600',
|
||||
marginBottom: SPACING.sm,
|
||||
},
|
||||
});
|
||||
51
packages/mobile/src/components/Input.tsx
Normal file
51
packages/mobile/src/components/Input.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, TextInput, TextInputProps, Text, View } from 'react-native';
|
||||
import { COLORS, BORDER_RADIUS, FONT_SIZES } from '@/constants/theme';
|
||||
|
||||
interface InputProps extends Omit<TextInputProps, 'style'> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
containerStyle?: object;
|
||||
}
|
||||
|
||||
export function Input({ label, error, containerStyle, ...props }: InputProps) {
|
||||
return (
|
||||
<View style={[styles.container, containerStyle]}>
|
||||
{label && <Text style={styles.label}>{label}</Text>}
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
error ? { borderColor: COLORS.danger } : null,
|
||||
]}
|
||||
placeholderTextColor={COLORS.textMuted}
|
||||
{...props}
|
||||
/>
|
||||
{error && <Text style={styles.error}>{error}</Text>}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
color: COLORS.textSecondary,
|
||||
fontSize: FONT_SIZES.sm,
|
||||
marginBottom: 6,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: COLORS.backgroundLight,
|
||||
borderColor: COLORS.border,
|
||||
borderWidth: 1,
|
||||
borderRadius: BORDER_RADIUS.md,
|
||||
padding: 12,
|
||||
color: COLORS.text,
|
||||
fontSize: FONT_SIZES.md,
|
||||
},
|
||||
error: {
|
||||
color: COLORS.danger,
|
||||
fontSize: FONT_SIZES.xs,
|
||||
marginTop: 4,
|
||||
},
|
||||
});
|
||||
82
packages/mobile/src/components/Loading.tsx
Normal file
82
packages/mobile/src/components/Loading.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, View, ActivityIndicator, Text } from 'react-native';
|
||||
import { COLORS, SPACING } from '@/constants/theme';
|
||||
|
||||
interface LoadingOverlayProps {
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
export function LoadingOverlay({ visible }: LoadingOverlayProps) {
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<View style={styles.overlay}>
|
||||
<ActivityIndicator size="large" color={COLORS.primary} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface EmptyStateProps {
|
||||
title: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export function EmptyState({ title, message }: EmptyStateProps) {
|
||||
return (
|
||||
<View style={styles.emptyContainer}>
|
||||
<View style={styles.emptyContent}>
|
||||
<View style={styles.emptyIcon}>
|
||||
<Text style={styles.emptyIconText}>📭</Text>
|
||||
</View>
|
||||
<View style={styles.emptyText}>
|
||||
<Text style={styles.emptyTitle}>{title}</Text>
|
||||
{message && <Text style={styles.emptyMessage}>{message}</Text>}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 999,
|
||||
elevation: 999,
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: SPACING.xl,
|
||||
},
|
||||
emptyContent: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
emptyIcon: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
backgroundColor: COLORS.card,
|
||||
marginBottom: SPACING.md,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
emptyIconText: {
|
||||
fontSize: 24,
|
||||
},
|
||||
emptyText: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
emptyTitle: {
|
||||
color: COLORS.textSecondary,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
emptyMessage: {
|
||||
color: COLORS.textMuted,
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
45
packages/mobile/src/components/StatCard.tsx
Normal file
45
packages/mobile/src/components/StatCard.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { COLORS, FONT_SIZES, SPACING } from '@/constants/theme';
|
||||
|
||||
interface StatCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
export function StatCard({ title, value, color = COLORS.primary, subtitle }: StatCardProps) {
|
||||
return (
|
||||
<View style={[styles.card, { borderLeftColor: color }]}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<Text style={[styles.value, { color }]}>{value}</Text>
|
||||
{subtitle && <Text style={styles.subtitle}>{subtitle}</Text>}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: COLORS.card,
|
||||
borderRadius: 8,
|
||||
padding: SPACING.md,
|
||||
marginBottom: SPACING.sm,
|
||||
borderLeftWidth: 3,
|
||||
},
|
||||
title: {
|
||||
color: COLORS.textSecondary,
|
||||
fontSize: FONT_SIZES.sm,
|
||||
marginBottom: SPACING.xs,
|
||||
},
|
||||
value: {
|
||||
fontSize: FONT_SIZES.xxl,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
subtitle: {
|
||||
color: COLORS.textMuted,
|
||||
fontSize: FONT_SIZES.xs,
|
||||
marginTop: SPACING.xs,
|
||||
},
|
||||
});
|
||||
5
packages/mobile/src/components/index.ts
Normal file
5
packages/mobile/src/components/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './Button';
|
||||
export * from './Input';
|
||||
export * from './StatCard';
|
||||
export * from './Card';
|
||||
export * from './Loading';
|
||||
11
packages/mobile/src/constants/index.ts
Normal file
11
packages/mobile/src/constants/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export const TAB_ORDER = ['Dashboard', 'DarkWatch', 'SpamShield', 'VoicePrint', 'Settings'] as const;
|
||||
|
||||
export const AUTH_STORAGE_KEY = '@shieldai_auth';
|
||||
export const BIOMETRIC_ENABLED_KEY = '@shieldai_biometric';
|
||||
export const ONBOARDING_COMPLETE_KEY = '@shieldai_onboarding';
|
||||
|
||||
export const NOTIFICATION_TYPES = {
|
||||
DARKWATCH_ALERT: 'darkwatch_alert',
|
||||
SPAM_BLOCKED: 'spam_blocked',
|
||||
VOICEPRINT_ANALYSIS: 'voiceprint_analysis',
|
||||
} as const;
|
||||
95
packages/mobile/src/constants/theme.ts
Normal file
95
packages/mobile/src/constants/theme.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
export const COLORS = {
|
||||
primary: '#4f8cff',
|
||||
primaryDark: '#3a6fd8',
|
||||
secondary: '#6c5ce7',
|
||||
accent: '#00cec9',
|
||||
success: '#00b894',
|
||||
warning: '#fdcb6e',
|
||||
danger: '#ff6b6b',
|
||||
background: '#0a0e1a',
|
||||
backgroundLight: '#111827',
|
||||
card: '#1a2035',
|
||||
cardLight: '#243049',
|
||||
text: '#e8eaf0',
|
||||
textSecondary: '#8b95a8',
|
||||
textMuted: '#5a6577',
|
||||
border: '#2a3550',
|
||||
overlay: 'rgba(0, 0, 0, 0.6)',
|
||||
} as const;
|
||||
|
||||
export const SPACING = {
|
||||
xs: 4,
|
||||
sm: 8,
|
||||
md: 16,
|
||||
lg: 24,
|
||||
xl: 32,
|
||||
xxl: 48,
|
||||
} as const;
|
||||
|
||||
export const FONT_SIZES = {
|
||||
xs: 12,
|
||||
sm: 14,
|
||||
md: 16,
|
||||
lg: 18,
|
||||
xl: 20,
|
||||
xxl: 24,
|
||||
xxxl: 32,
|
||||
} as const;
|
||||
|
||||
export const BORDER_RADIUS = {
|
||||
sm: 4,
|
||||
md: 8,
|
||||
lg: 12,
|
||||
xl: 16,
|
||||
round: 999,
|
||||
} as const;
|
||||
|
||||
export const SHADOWS = {
|
||||
sm: { shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.1, shadowRadius: 2, elevation: 2 },
|
||||
md: { shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.15, shadowRadius: 4, elevation: 4 },
|
||||
lg: { shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.2, shadowRadius: 8, elevation: 8 },
|
||||
} as const;
|
||||
|
||||
export const API_URL = process.env.EXPO_PUBLIC_API_URL || 'https://api.shieldai.freno.me/api/v1';
|
||||
|
||||
export const EAS_PROJECT_ID = process.env.EXPO_PUBLIC_EAS_PROJECT_ID || '';
|
||||
|
||||
export const getSeverityColor = (severity: string): string => {
|
||||
switch (severity) {
|
||||
case 'critical': return COLORS.danger;
|
||||
case 'high': return COLORS.warning;
|
||||
case 'medium': return COLORS.accent;
|
||||
default: return COLORS.textSecondary;
|
||||
}
|
||||
};
|
||||
|
||||
export const TIER_FEATURES = {
|
||||
free: {
|
||||
name: 'Free',
|
||||
maxExposures: 5,
|
||||
spamProtection: false,
|
||||
voicePrint: false,
|
||||
darkWatch: false,
|
||||
},
|
||||
basic: {
|
||||
name: 'Basic',
|
||||
maxExposures: 50,
|
||||
spamProtection: true,
|
||||
voicePrint: false,
|
||||
darkWatch: true,
|
||||
},
|
||||
premium: {
|
||||
name: 'Premium',
|
||||
maxExposures: 200,
|
||||
spamProtection: true,
|
||||
voicePrint: true,
|
||||
darkWatch: true,
|
||||
},
|
||||
enterprise: {
|
||||
name: 'Enterprise',
|
||||
maxExposures: -1,
|
||||
spamProtection: true,
|
||||
voicePrint: true,
|
||||
darkWatch: true,
|
||||
},
|
||||
} as const;
|
||||
1
packages/mobile/src/global.d.ts
vendored
Normal file
1
packages/mobile/src/global.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare const __DEV__: boolean;
|
||||
3
packages/mobile/src/hooks/index.ts
Normal file
3
packages/mobile/src/hooks/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './usePushNotifications';
|
||||
export * from './useBiometricAuth';
|
||||
export * from './useNetworkStatus';
|
||||
78
packages/mobile/src/hooks/useBiometricAuth.ts
Normal file
78
packages/mobile/src/hooks/useBiometricAuth.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useCallback, useState, useEffect } from 'react';
|
||||
import * as LocalAuthentication from 'expo-local-authentication';
|
||||
import { Alert } from 'react-native';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
|
||||
export function useBiometricAuth() {
|
||||
const { isBiometricEnabled } = useSettingsStore();
|
||||
const [biometricsAvailable, setBiometricsAvailable] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const hasHardware = await LocalAuthentication.hasHardwareAsync();
|
||||
const isEnrolled = await LocalAuthentication.isEnrolledAsync();
|
||||
setBiometricsAvailable(hasHardware && isEnrolled);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const authenticate = useCallback(async (): Promise<boolean> => {
|
||||
if (!isBiometricEnabled) return true;
|
||||
|
||||
try {
|
||||
const isAvailable = await LocalAuthentication.hasHardwareAsync();
|
||||
const isEnrolled = await LocalAuthentication.isEnrolledAsync();
|
||||
|
||||
if (!isAvailable || !isEnrolled) {
|
||||
Alert.alert(
|
||||
'Biometric Unavailable',
|
||||
'Biometric authentication requires hardware and enrollment. Please use your password.',
|
||||
[{ text: 'OK' }]
|
||||
);
|
||||
useSettingsStore.getState().toggleBiometric(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = await LocalAuthentication.authenticateAsync({
|
||||
promptMessage: 'Authenticate with biometrics',
|
||||
fallbackLabel: 'Use passcode',
|
||||
cancelLabel: 'Cancel',
|
||||
disableDeviceFallback: false,
|
||||
});
|
||||
|
||||
return result.success;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, [isBiometricEnabled]);
|
||||
|
||||
const enableBiometric = useCallback(async (): Promise<boolean> => {
|
||||
const isAvailable = await LocalAuthentication.hasHardwareAsync();
|
||||
const isEnrolled = await LocalAuthentication.isEnrolledAsync();
|
||||
|
||||
if (!isAvailable || !isEnrolled) {
|
||||
Alert.alert(
|
||||
'Biometric Not Available',
|
||||
'Your device does not support biometric authentication or no credentials are enrolled.',
|
||||
[{ text: 'OK' }]
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = await LocalAuthentication.authenticateAsync({
|
||||
promptMessage: 'Enable biometric authentication',
|
||||
fallbackLabel: 'Use passcode',
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
useSettingsStore.getState().toggleBiometric(true);
|
||||
}
|
||||
|
||||
return result.success;
|
||||
}, []);
|
||||
|
||||
const disableBiometric = useCallback(() => {
|
||||
useSettingsStore.getState().toggleBiometric(false);
|
||||
}, []);
|
||||
|
||||
return { authenticate, enableBiometric, disableBiometric, isBiometricEnabled, biometricsAvailable };
|
||||
}
|
||||
18
packages/mobile/src/hooks/useNetworkStatus.ts
Normal file
18
packages/mobile/src/hooks/useNetworkStatus.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import NetInfo from '@react-native-community/netinfo';
|
||||
|
||||
export function useNetworkStatus() {
|
||||
const [isConnected, setIsConnected] = useState(true);
|
||||
const [isOnline, setIsOnline] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = NetInfo.addEventListener((state) => {
|
||||
setIsConnected(!!state.isConnected);
|
||||
setIsOnline(!!state.isInternetReachable);
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
return { isConnected, isOnline, isOffline: !isOnline };
|
||||
}
|
||||
86
packages/mobile/src/hooks/usePushNotifications.ts
Normal file
86
packages/mobile/src/hooks/usePushNotifications.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useEffect, useCallback, useRef } from 'react';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import { Platform } from 'react-native';
|
||||
import * as Device from 'expo-device';
|
||||
import { deviceService, notificationService } from '@shieldai/mobile-api-client';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { EAS_PROJECT_ID } from '@/constants/theme';
|
||||
|
||||
export function usePushNotifications() {
|
||||
const preferencesRef = useRef(useSettingsStore.getState().preferences);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = useSettingsStore.subscribe((state) => {
|
||||
preferencesRef.current = state.preferences;
|
||||
});
|
||||
return subscription;
|
||||
}, []);
|
||||
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => {
|
||||
const prefs = preferencesRef.current;
|
||||
return {
|
||||
shouldShowAlert: prefs.pushNotifications,
|
||||
shouldPlaySound: prefs.pushNotifications,
|
||||
shouldSetBadge: false,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const registerForPushNotifications = useCallback(async () => {
|
||||
try {
|
||||
const { status: existingStatus } = await Notifications.getPermissionsAsync();
|
||||
let finalStatus = existingStatus;
|
||||
|
||||
if (existingStatus !== 'granted') {
|
||||
const { status } = await Notifications.requestPermissionsAsync();
|
||||
finalStatus = status;
|
||||
}
|
||||
|
||||
if (finalStatus !== 'granted') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!EAS_PROJECT_ID) {
|
||||
console.warn('EAS_PROJECT_ID not configured — push notifications disabled');
|
||||
return null;
|
||||
}
|
||||
|
||||
const token = (await Notifications.getExpoPushTokenAsync({
|
||||
projectId: EAS_PROJECT_ID,
|
||||
})).data;
|
||||
|
||||
try {
|
||||
await deviceService.registerDevice({
|
||||
platform: Platform.OS === 'ios' ? 'ios' : 'android',
|
||||
pushToken: token,
|
||||
modelName: Platform.OS === 'ios' ? 'iPhone' : 'Android',
|
||||
osVersion: Device.osVersion || '0',
|
||||
appVersion: '1.0.0',
|
||||
});
|
||||
} catch (deviceError) {
|
||||
console.warn('Device registration failed (will retry on next launch):', deviceError);
|
||||
}
|
||||
|
||||
return token;
|
||||
} catch (error) {
|
||||
console.error('Failed to register for push notifications:', error);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = Notifications.addNotificationReceivedListener((notification) => {
|
||||
const type = notification.request.content.data?.type;
|
||||
const prefs = preferencesRef.current;
|
||||
|
||||
if (type === 'darkwatch_alert' && !prefs.darkwatchAlert) return;
|
||||
if (type === 'spam_blocked' && !prefs.spamBlocked) return;
|
||||
if (type === 'voiceprint_analysis' && !prefs.voiceprintAnalysis) return;
|
||||
});
|
||||
|
||||
return () => subscription.remove();
|
||||
}, []);
|
||||
|
||||
return { registerForPushNotifications };
|
||||
}
|
||||
24
packages/mobile/src/navigation/AuthNavigator.tsx
Normal file
24
packages/mobile/src/navigation/AuthNavigator.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import { createStackNavigator } from '@react-navigation/stack';
|
||||
import { LoginScreen, RegisterScreen } from '@/screens/auth';
|
||||
|
||||
type AuthStackParamList = {
|
||||
Login: undefined;
|
||||
Register: undefined;
|
||||
};
|
||||
|
||||
const Stack = createStackNavigator<AuthStackParamList>();
|
||||
|
||||
export function AuthNavigator() {
|
||||
return (
|
||||
<Stack.Navigator
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
cardStyle: { backgroundColor: '#0a0e1a' },
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="Login" component={LoginScreen} />
|
||||
<Stack.Screen name="Register" component={RegisterScreen} />
|
||||
</Stack.Navigator>
|
||||
);
|
||||
}
|
||||
124
packages/mobile/src/navigation/MainTabNavigator.tsx
Normal file
124
packages/mobile/src/navigation/MainTabNavigator.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import React from 'react';
|
||||
import { Text, ViewStyle, StyleSheet } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||
import { DashboardScreen } from '@/screens/dashboard';
|
||||
import { DarkWatchScreen } from '@/screens/darkwatch';
|
||||
import { SpamShieldScreen } from '@/screens/spamshield';
|
||||
import { VoicePrintScreen } from '@/screens/voiceprint';
|
||||
import { SettingsScreen } from '@/screens/settings';
|
||||
import { COLORS, FONT_SIZES } from '@/constants/theme';
|
||||
|
||||
type MainTabParamList = {
|
||||
Dashboard: undefined;
|
||||
DarkWatch: undefined;
|
||||
SpamShield: undefined;
|
||||
VoicePrint: undefined;
|
||||
Settings: undefined;
|
||||
};
|
||||
|
||||
const Tab = createBottomTabNavigator<MainTabParamList>();
|
||||
|
||||
const iconMap: Record<string, keyof typeof Ionicons.glyphMap> = {
|
||||
Dashboard: 'shield-outline',
|
||||
DarkWatch: 'eye-outline',
|
||||
SpamShield: 'ban-outline',
|
||||
VoicePrint: 'mic-outline',
|
||||
Settings: 'settings-outline',
|
||||
};
|
||||
|
||||
const iconActiveMap: Record<string, keyof typeof Ionicons.glyphMap> = {
|
||||
Dashboard: 'shield',
|
||||
DarkWatch: 'eye',
|
||||
SpamShield: 'ban',
|
||||
VoicePrint: 'mic',
|
||||
Settings: 'settings',
|
||||
};
|
||||
|
||||
function TabIcon({ routeName, color, focused }: { routeName: string; color: string; focused: boolean }) {
|
||||
const iconName = focused
|
||||
? (iconActiveMap[routeName] as keyof typeof Ionicons.glyphMap)
|
||||
: (iconMap[routeName] as keyof typeof Ionicons.glyphMap);
|
||||
|
||||
return <Ionicons name={iconName} size={24} color={color} />;
|
||||
}
|
||||
|
||||
export function MainTabNavigator() {
|
||||
return (
|
||||
<Tab.Navigator
|
||||
screenOptions={{
|
||||
headerStyle: {
|
||||
backgroundColor: COLORS.background,
|
||||
},
|
||||
headerTintColor: COLORS.text,
|
||||
headerTitleStyle: {
|
||||
fontSize: FONT_SIZES.lg,
|
||||
fontWeight: '600',
|
||||
},
|
||||
tabBarStyle: {
|
||||
backgroundColor: COLORS.backgroundLight,
|
||||
borderTopColor: COLORS.border,
|
||||
borderTopWidth: 1,
|
||||
height: 60,
|
||||
paddingBottom: 8,
|
||||
paddingTop: 8,
|
||||
} as ViewStyle,
|
||||
tabBarActiveTintColor: COLORS.primary,
|
||||
tabBarInactiveTintColor: COLORS.textMuted,
|
||||
tabBarLabelStyle: {
|
||||
fontSize: FONT_SIZES.xs,
|
||||
},
|
||||
tabBarIconStyle: {
|
||||
marginTop: 4,
|
||||
},
|
||||
tabBarShowLabel: true,
|
||||
}}
|
||||
>
|
||||
<Tab.Screen
|
||||
name="Dashboard"
|
||||
component={DashboardScreen}
|
||||
options={{
|
||||
headerTitle: 'Dashboard',
|
||||
tabBarLabel: 'Home',
|
||||
tabBarIcon: ({ color, focused }) => <TabIcon routeName="Dashboard" color={color} focused={focused} />,
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="DarkWatch"
|
||||
component={DarkWatchScreen}
|
||||
options={{
|
||||
headerTitle: 'DarkWatch',
|
||||
tabBarLabel: 'DarkWatch',
|
||||
tabBarIcon: ({ color, focused }) => <TabIcon routeName="DarkWatch" color={color} focused={focused} />,
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="SpamShield"
|
||||
component={SpamShieldScreen}
|
||||
options={{
|
||||
headerTitle: 'SpamShield',
|
||||
tabBarLabel: 'SpamShield',
|
||||
tabBarIcon: ({ color, focused }) => <TabIcon routeName="SpamShield" color={color} focused={focused} />,
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="VoicePrint"
|
||||
component={VoicePrintScreen}
|
||||
options={{
|
||||
headerTitle: 'VoicePrint',
|
||||
tabBarLabel: 'VoicePrint',
|
||||
tabBarIcon: ({ color, focused }) => <TabIcon routeName="VoicePrint" color={color} focused={focused} />,
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Settings"
|
||||
component={SettingsScreen}
|
||||
options={{
|
||||
headerTitle: 'Settings',
|
||||
tabBarLabel: 'Settings',
|
||||
tabBarIcon: ({ color, focused }) => <TabIcon routeName="Settings" color={color} focused={focused} />,
|
||||
}}
|
||||
/>
|
||||
</Tab.Navigator>
|
||||
);
|
||||
}
|
||||
2
packages/mobile/src/navigation/index.ts
Normal file
2
packages/mobile/src/navigation/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './AuthNavigator';
|
||||
export * from './MainTabNavigator';
|
||||
179
packages/mobile/src/screens/auth/LoginScreen.tsx
Normal file
179
packages/mobile/src/screens/auth/LoginScreen.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import React, { useState } from 'react';
|
||||
import { StyleSheet, Text, View, SafeAreaView, KeyboardAvoidingView, Platform, ScrollView, Alert } from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { useNavigation, NavigationProp } from '@react-navigation/native';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { Button, Input } from '@/components';
|
||||
import { COLORS, FONT_SIZES, SPACING } from '@/constants/theme';
|
||||
|
||||
type RootStackParamList = {
|
||||
Login: undefined;
|
||||
Register: undefined;
|
||||
};
|
||||
|
||||
const LOGIN_ATTEMPT_KEY = '@shieldai_login_attempts';
|
||||
const MAX_ATTEMPTS = 5;
|
||||
const LOCKOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
export function LoginScreen() {
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
const { login, isLoading, clearError } = useAuthStore();
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [formError, setFormError] = useState('');
|
||||
|
||||
const handleLogin = async () => {
|
||||
setFormError('');
|
||||
clearError();
|
||||
|
||||
if (!email || !password) {
|
||||
setFormError('Please fill in all fields');
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const stored = await AsyncStorage.getItem(LOGIN_ATTEMPT_KEY);
|
||||
let attempts = { count: 0, lockedUntil: 0 };
|
||||
if (stored) {
|
||||
try {
|
||||
attempts = JSON.parse(stored);
|
||||
} catch { /* ignore corrupt data */ }
|
||||
}
|
||||
|
||||
if (attempts.lockedUntil > now) {
|
||||
const remaining = Math.ceil((attempts.lockedUntil - now) / 1000 / 60);
|
||||
setFormError(`Too many failed attempts. Try again in ${remaining} minute${remaining !== 1 ? 's' : ''}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (attempts.count >= MAX_ATTEMPTS && attempts.lockedUntil <= now) {
|
||||
attempts.lockedUntil = now + LOCKOUT_MS;
|
||||
attempts.count = 0;
|
||||
await AsyncStorage.setItem(LOGIN_ATTEMPT_KEY, JSON.stringify(attempts));
|
||||
const remaining = Math.ceil(LOCKOUT_MS / 1000 / 60);
|
||||
setFormError(`Too many failed attempts. Locked for ${remaining} minutes.`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await login(email, password);
|
||||
await AsyncStorage.removeItem(LOGIN_ATTEMPT_KEY);
|
||||
} catch (err: any) {
|
||||
attempts.count = (attempts.count || 0) + 1;
|
||||
if (attempts.count >= MAX_ATTEMPTS) {
|
||||
attempts.lockedUntil = Date.now() + LOCKOUT_MS;
|
||||
}
|
||||
await AsyncStorage.setItem(LOGIN_ATTEMPT_KEY, JSON.stringify(attempts));
|
||||
const remaining = MAX_ATTEMPTS - attempts.count;
|
||||
const warning = remaining > 0 ? ` ${remaining} attempt${remaining !== 1 ? 's' : ''} remaining.` : '';
|
||||
setFormError(err.message || 'Login failed. Please try again.' + warning);
|
||||
Alert.alert('Login Failed', err.message || 'Please check your credentials and try again.' + warning);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.keyboardView}
|
||||
>
|
||||
<ScrollView contentContainerStyle={styles.scrollContent}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.logo}>ShieldAI</Text>
|
||||
<Text style={styles.tagline}>Your digital protection suite</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.form}>
|
||||
{formError && <Text style={styles.error}>{formError}</Text>}
|
||||
|
||||
<Input
|
||||
label="Email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
autoComplete="email"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry
|
||||
autoComplete="password"
|
||||
/>
|
||||
|
||||
<Button
|
||||
title={isLoading ? 'Signing in...' : 'Sign In'}
|
||||
onPress={handleLogin}
|
||||
disabled={isLoading}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Text style={styles.footerText}>Don't have an account? </Text>
|
||||
<Text style={styles.link} onPress={() => navigation.navigate('Register')}>
|
||||
Sign Up
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: COLORS.background,
|
||||
},
|
||||
keyboardView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
justifyContent: 'center',
|
||||
padding: SPACING.lg,
|
||||
},
|
||||
header: {
|
||||
alignItems: 'center',
|
||||
marginBottom: SPACING.xxl,
|
||||
},
|
||||
logo: {
|
||||
color: COLORS.primary,
|
||||
fontSize: FONT_SIZES.xxxl,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
tagline: {
|
||||
color: COLORS.textSecondary,
|
||||
fontSize: FONT_SIZES.md,
|
||||
marginTop: SPACING.sm,
|
||||
},
|
||||
form: {
|
||||
width: '100%',
|
||||
},
|
||||
error: {
|
||||
color: COLORS.danger,
|
||||
fontSize: FONT_SIZES.sm,
|
||||
marginBottom: SPACING.md,
|
||||
textAlign: 'center',
|
||||
},
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
marginTop: SPACING.lg,
|
||||
},
|
||||
footerText: {
|
||||
color: COLORS.textSecondary,
|
||||
fontSize: FONT_SIZES.sm,
|
||||
},
|
||||
link: {
|
||||
color: COLORS.primary,
|
||||
fontSize: FONT_SIZES.sm,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
178
packages/mobile/src/screens/auth/RegisterScreen.tsx
Normal file
178
packages/mobile/src/screens/auth/RegisterScreen.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import React, { useState } from 'react';
|
||||
import { StyleSheet, Text, View, SafeAreaView, KeyboardAvoidingView, Platform, ScrollView, Alert } from 'react-native';
|
||||
import { useNavigation, NavigationProp } from '@react-navigation/native';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { Button, Input } from '@/components';
|
||||
import { COLORS, FONT_SIZES, SPACING } from '@/constants/theme';
|
||||
|
||||
type RootStackParamList = {
|
||||
Login: undefined;
|
||||
Register: undefined;
|
||||
};
|
||||
|
||||
export function RegisterScreen() {
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
const { register, isLoading } = useAuthStore();
|
||||
|
||||
const [firstName, setFirstName] = useState('');
|
||||
const [lastName, setLastName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [formError, setFormError] = useState('');
|
||||
|
||||
const handleRegister = async () => {
|
||||
setFormError('');
|
||||
|
||||
if (!firstName || !lastName || !email || !password) {
|
||||
setFormError('Please fill in all fields');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setFormError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
setFormError('Password must be at least 8 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await register(email, password, firstName, lastName);
|
||||
} catch (err: any) {
|
||||
setFormError(err.message || 'Registration failed. Please try again.');
|
||||
Alert.alert('Registration Failed', err.message || 'Please try again with different credentials.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.keyboardView}
|
||||
>
|
||||
<ScrollView contentContainerStyle={styles.scrollContent}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Create Account</Text>
|
||||
<Text style={styles.subtitle}>Join ShieldAI today</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.form}>
|
||||
{formError && <Text style={styles.error}>{formError}</Text>}
|
||||
|
||||
<Input
|
||||
label="First Name"
|
||||
placeholder="John"
|
||||
value={firstName}
|
||||
onChangeText={setFirstName}
|
||||
autoCapitalize="words"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Last Name"
|
||||
placeholder="Doe"
|
||||
value={lastName}
|
||||
onChangeText={setLastName}
|
||||
autoCapitalize="words"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
autoComplete="email"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry
|
||||
autoComplete="password"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Confirm Password"
|
||||
placeholder="••••••••"
|
||||
value={confirmPassword}
|
||||
onChangeText={setConfirmPassword}
|
||||
secureTextEntry
|
||||
autoComplete="password"
|
||||
/>
|
||||
|
||||
<Button
|
||||
title={isLoading ? 'Creating account...' : 'Create Account'}
|
||||
onPress={handleRegister}
|
||||
disabled={isLoading}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Text style={styles.footerText}>Already have an account? </Text>
|
||||
<Text style={styles.link} onPress={() => navigation.navigate('Login')}>
|
||||
Sign In
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: COLORS.background,
|
||||
},
|
||||
keyboardView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
padding: SPACING.lg,
|
||||
},
|
||||
header: {
|
||||
alignItems: 'center',
|
||||
marginBottom: SPACING.xl,
|
||||
},
|
||||
title: {
|
||||
color: COLORS.text,
|
||||
fontSize: FONT_SIZES.xxxl,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
subtitle: {
|
||||
color: COLORS.textSecondary,
|
||||
fontSize: FONT_SIZES.md,
|
||||
marginTop: SPACING.xs,
|
||||
},
|
||||
form: {
|
||||
width: '100%',
|
||||
},
|
||||
error: {
|
||||
color: COLORS.danger,
|
||||
fontSize: FONT_SIZES.sm,
|
||||
marginBottom: SPACING.md,
|
||||
textAlign: 'center',
|
||||
},
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
marginTop: SPACING.lg,
|
||||
},
|
||||
footerText: {
|
||||
color: COLORS.textSecondary,
|
||||
fontSize: FONT_SIZES.sm,
|
||||
},
|
||||
link: {
|
||||
color: COLORS.primary,
|
||||
fontSize: FONT_SIZES.sm,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
2
packages/mobile/src/screens/auth/index.ts
Normal file
2
packages/mobile/src/screens/auth/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './LoginScreen';
|
||||
export * from './RegisterScreen';
|
||||
337
packages/mobile/src/screens/darkwatch/DarkWatchScreen.tsx
Normal file
337
packages/mobile/src/screens/darkwatch/DarkWatchScreen.tsx
Normal file
@@ -0,0 +1,337 @@
|
||||
import React, { useState } from 'react';
|
||||
import { StyleSheet, Text, View, SafeAreaView, ScrollView, FlatList, TouchableOpacity, Alert, Modal } from 'react-native';
|
||||
import { useDarkWatchStore } from '@/store/darkWatchStore';
|
||||
import { Card, Button, Input, EmptyState } from '@/components';
|
||||
import { COLORS, FONT_SIZES, SPACING, BORDER_RADIUS, getSeverityColor } from '@/constants/theme';
|
||||
import type { WatchListItem } from '@/types';
|
||||
|
||||
export function DarkWatchScreen() {
|
||||
const { watchList, exposures, addWatchItem, removeWatchItem, toggleAlert } = useDarkWatchStore();
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [newItemName, setNewItemName] = useState('');
|
||||
const [newItemValue, setNewItemValue] = useState('');
|
||||
const [newItemType, setNewItemType] = useState<WatchListItem['entityType']>('person');
|
||||
const [validationError, setValidationError] = useState('');
|
||||
|
||||
const validateWatchItem = (name: string, value: string, type: string): string | null => {
|
||||
if (!name.trim() || !value.trim()) return 'Name and value are required';
|
||||
if (name.length > 100) return 'Name must be 100 characters or less';
|
||||
if (value.length > 200) return 'Value must be 200 characters or less';
|
||||
if (type === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
|
||||
return 'Enter a valid email address';
|
||||
}
|
||||
if (type === 'phone' && !/^\+?[\d\s-()]{7,20}$/.test(value)) {
|
||||
return 'Enter a valid phone number';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleAddItem = async () => {
|
||||
const error = validateWatchItem(newItemName, newItemValue, newItemType);
|
||||
if (error) {
|
||||
setValidationError(error);
|
||||
return;
|
||||
}
|
||||
setValidationError('');
|
||||
|
||||
await addWatchItem({
|
||||
name: newItemName,
|
||||
entityType: newItemType,
|
||||
value: newItemValue,
|
||||
alertEnabled: true,
|
||||
});
|
||||
|
||||
setNewItemName('');
|
||||
setNewItemValue('');
|
||||
setShowAddModal(false);
|
||||
};
|
||||
|
||||
const handleRemoveItem = (id: string, name: string) => {
|
||||
Alert.alert(
|
||||
'Remove Watch Item',
|
||||
`Remove "${name}" from your watch list?`,
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Remove',
|
||||
style: 'destructive',
|
||||
onPress: () => removeWatchItem(id),
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const renderItem = ({ item }: { item: WatchListItem }) => (
|
||||
<View style={styles.watchItem}>
|
||||
<View style={styles.watchItemLeft}>
|
||||
<View style={[styles.entityBadge, { backgroundColor: COLORS.primary }]} />
|
||||
<View style={styles.watchItemInfo}>
|
||||
<Text style={styles.watchItemName}>{item.name}</Text>
|
||||
<Text style={styles.watchItemValue}>{item.value}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.watchItemActions}>
|
||||
<TouchableOpacity
|
||||
style={[styles.alertToggle, { opacity: item.alertEnabled ? 1 : 0.4 }]}
|
||||
onPress={() => toggleAlert(item.id)}
|
||||
>
|
||||
<Text style={styles.alertToggleText}>{item.alertEnabled ? '🔔' : '🔕'}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={styles.removeButton}
|
||||
onPress={() => handleRemoveItem(item.id, item.name)}
|
||||
>
|
||||
<Text style={styles.removeButtonText}>✕</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>DarkWatch</Text>
|
||||
<Text style={styles.subtitle}>Monitor your digital footprint</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.content}>
|
||||
<Card title="Watch List">
|
||||
<Button
|
||||
title="+ Add Watch Item"
|
||||
onPress={() => setShowAddModal(true)}
|
||||
variant="secondary"
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
{watchList.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No watch items yet"
|
||||
message="Add people, emails, or phone numbers to monitor"
|
||||
/>
|
||||
) : (
|
||||
<FlatList
|
||||
data={watchList}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item) => item.id}
|
||||
scrollEnabled={false}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title="Recent Exposures">
|
||||
{exposures.length === 0 ? (
|
||||
<EmptyState title="No recent exposures" message="DarkWatch is monitoring for new data leaks" />
|
||||
) : (
|
||||
<FlatList
|
||||
data={exposures}
|
||||
keyExtractor={(item) => item.id}
|
||||
scrollEnabled={false}
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.exposureItem}>
|
||||
<Text style={styles.exposureSource}>{item.source}</Text>
|
||||
<Text style={[styles.exposureSeverity, { color: getSeverityColor(item.severity) }]}>
|
||||
{item.severity.toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</ScrollView>
|
||||
|
||||
<Modal visible={showAddModal} animationType="slide" transparent>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={styles.modalContent}>
|
||||
<Text style={styles.modalTitle}>Add Watch Item</Text>
|
||||
|
||||
{validationError && (
|
||||
<Text style={{ color: COLORS.danger, fontSize: FONT_SIZES.sm, marginBottom: SPACING.sm }}>
|
||||
{validationError}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label="Name"
|
||||
placeholder="e.g., John Doe"
|
||||
value={newItemName}
|
||||
onChangeText={setNewItemName}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Value"
|
||||
placeholder={`Enter ${newItemType}`}
|
||||
value={newItemValue}
|
||||
onChangeText={setNewItemValue}
|
||||
/>
|
||||
|
||||
<View style={styles.typeSelector}>
|
||||
{(['person', 'email', 'phone', 'address'] as const).map((type) => (
|
||||
<TouchableOpacity
|
||||
key={type}
|
||||
style={[
|
||||
styles.typeButton,
|
||||
{ backgroundColor: newItemType === type ? COLORS.primary : COLORS.card },
|
||||
]}
|
||||
onPress={() => setNewItemType(type)}
|
||||
>
|
||||
<Text style={[
|
||||
styles.typeButtonText,
|
||||
{ color: newItemType === type ? '#fff' : COLORS.textSecondary }
|
||||
]}>
|
||||
{type.charAt(0).toUpperCase() + type.slice(1)}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={styles.modalActions}>
|
||||
<Button
|
||||
title="Cancel"
|
||||
onPress={() => setShowAddModal(false)}
|
||||
variant="ghost"
|
||||
/>
|
||||
<Button
|
||||
title="Add"
|
||||
onPress={handleAddItem}
|
||||
variant="primary"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: COLORS.background,
|
||||
},
|
||||
header: {
|
||||
padding: SPACING.md,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: COLORS.border,
|
||||
},
|
||||
title: {
|
||||
color: COLORS.text,
|
||||
fontSize: FONT_SIZES.xxl,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
subtitle: {
|
||||
color: COLORS.textSecondary,
|
||||
fontSize: FONT_SIZES.sm,
|
||||
marginTop: SPACING.xs,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
watchItem: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingVertical: SPACING.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: COLORS.border,
|
||||
},
|
||||
watchItemLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
},
|
||||
entityBadge: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
marginRight: SPACING.sm,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
watchItemInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
watchItemName: {
|
||||
color: COLORS.text,
|
||||
fontSize: FONT_SIZES.md,
|
||||
fontWeight: '500',
|
||||
},
|
||||
watchItemValue: {
|
||||
color: COLORS.textSecondary,
|
||||
fontSize: FONT_SIZES.sm,
|
||||
},
|
||||
watchItemActions: {
|
||||
flexDirection: 'row',
|
||||
gap: SPACING.sm,
|
||||
},
|
||||
alertToggle: {
|
||||
padding: SPACING.xs,
|
||||
},
|
||||
alertToggleText: {
|
||||
fontSize: 16,
|
||||
},
|
||||
removeButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
backgroundColor: COLORS.cardLight,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
removeButtonText: {
|
||||
color: COLORS.danger,
|
||||
fontSize: 14,
|
||||
},
|
||||
exposureItem: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: SPACING.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: COLORS.border,
|
||||
},
|
||||
exposureSource: {
|
||||
color: COLORS.text,
|
||||
fontSize: FONT_SIZES.md,
|
||||
},
|
||||
exposureSeverity: {
|
||||
fontSize: FONT_SIZES.xs,
|
||||
fontWeight: '600',
|
||||
},
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
modalContent: {
|
||||
backgroundColor: COLORS.backgroundLight,
|
||||
borderTopLeftRadius: BORDER_RADIUS.xl,
|
||||
borderTopRightRadius: BORDER_RADIUS.xl,
|
||||
padding: SPACING.lg,
|
||||
paddingBottom: SPACING.xxl,
|
||||
},
|
||||
modalTitle: {
|
||||
color: COLORS.text,
|
||||
fontSize: FONT_SIZES.xl,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: SPACING.md,
|
||||
},
|
||||
typeSelector: {
|
||||
flexDirection: 'row',
|
||||
gap: SPACING.sm,
|
||||
marginBottom: SPACING.md,
|
||||
},
|
||||
typeButton: {
|
||||
flex: 1,
|
||||
paddingVertical: SPACING.sm,
|
||||
borderRadius: BORDER_RADIUS.md,
|
||||
alignItems: 'center',
|
||||
},
|
||||
typeButtonText: {
|
||||
fontSize: FONT_SIZES.sm,
|
||||
fontWeight: '500',
|
||||
},
|
||||
modalActions: {
|
||||
flexDirection: 'row',
|
||||
gap: SPACING.md,
|
||||
marginTop: SPACING.md,
|
||||
},
|
||||
});
|
||||
1
packages/mobile/src/screens/darkwatch/index.ts
Normal file
1
packages/mobile/src/screens/darkwatch/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './DarkWatchScreen';
|
||||
134
packages/mobile/src/screens/dashboard/DashboardScreen.tsx
Normal file
134
packages/mobile/src/screens/dashboard/DashboardScreen.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { StyleSheet, Text, View, SafeAreaView, ScrollView, RefreshControl } from 'react-native';
|
||||
import { useDashboardStore } from '@/store/dashboardStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { StatCard, Card, LoadingOverlay } from '@/components';
|
||||
import { COLORS, FONT_SIZES, SPACING } from '@/constants/theme';
|
||||
|
||||
export function DashboardScreen() {
|
||||
const { data, isLoading, refreshDashboard } = useDashboardStore();
|
||||
const { user } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) {
|
||||
refreshDashboard();
|
||||
}
|
||||
}, [data, refreshDashboard]);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.greeting}>
|
||||
Welcome back, {user?.firstName || 'User'}
|
||||
</Text>
|
||||
<Text style={styles.tier}>
|
||||
{user?.tier ? user?.tier.charAt(0).toUpperCase() + user?.tier.slice(1) : 'Free'} Plan
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isLoading}
|
||||
onRefresh={refreshDashboard}
|
||||
tintColor={COLORS.primary}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Card title="Exposure Summary">
|
||||
<StatCard
|
||||
title="Total Exposures"
|
||||
value={data?.exposureSummary.total ?? 0}
|
||||
color={COLORS.primary}
|
||||
/>
|
||||
<StatCard
|
||||
title="Unresolved"
|
||||
value={data?.exposureSummary.unresolved ?? 0}
|
||||
color={COLORS.warning}
|
||||
/>
|
||||
<StatCard
|
||||
title="Critical"
|
||||
value={data?.exposureSummary.critical ?? 0}
|
||||
color={COLORS.danger}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="SpamShield Stats">
|
||||
<StatCard
|
||||
title="Blocked Today"
|
||||
value={data?.spamStats.blockedToday ?? 0}
|
||||
color={COLORS.success}
|
||||
/>
|
||||
<StatCard
|
||||
title="Total Blocked"
|
||||
value={data?.spamStats.blockedTotal ?? 0}
|
||||
color={COLORS.accent}
|
||||
/>
|
||||
<StatCard
|
||||
title="Spam Score"
|
||||
value={`${data?.spamStats.spamScore ?? 0}%`}
|
||||
color={COLORS.secondary}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="VoicePrint Status">
|
||||
<View style={styles.voiceStatus}>
|
||||
<View style={styles.statusRow}>
|
||||
<View style={[styles.statusDot, { backgroundColor: data?.voiceProtectionStatus.isMonitoring ? COLORS.success : COLORS.textMuted }]} />
|
||||
<Text style={styles.statusText}>
|
||||
{data?.voiceProtectionStatus.isMonitoring ? 'Monitoring Active' : 'Monitoring Inactive'}
|
||||
</Text>
|
||||
</View>
|
||||
<StatCard
|
||||
title="Profiles Enrolled"
|
||||
value={data?.voiceProtectionStatus.profilesEnrolled ?? 0}
|
||||
color={COLORS.accent}
|
||||
/>
|
||||
</View>
|
||||
</Card>
|
||||
</ScrollView>
|
||||
|
||||
<LoadingOverlay visible={isLoading} />
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: COLORS.background,
|
||||
},
|
||||
header: {
|
||||
padding: SPACING.md,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: COLORS.border,
|
||||
},
|
||||
greeting: {
|
||||
color: COLORS.text,
|
||||
fontSize: FONT_SIZES.xxl,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
tier: {
|
||||
color: COLORS.primary,
|
||||
fontSize: FONT_SIZES.sm,
|
||||
marginTop: SPACING.xs,
|
||||
},
|
||||
voiceStatus: {
|
||||
gap: SPACING.sm,
|
||||
},
|
||||
statusRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: SPACING.sm,
|
||||
},
|
||||
statusDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
marginRight: SPACING.sm,
|
||||
},
|
||||
statusText: {
|
||||
color: COLORS.text,
|
||||
fontSize: FONT_SIZES.md,
|
||||
},
|
||||
});
|
||||
1
packages/mobile/src/screens/dashboard/index.ts
Normal file
1
packages/mobile/src/screens/dashboard/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './DashboardScreen';
|
||||
292
packages/mobile/src/screens/settings/SettingsScreen.tsx
Normal file
292
packages/mobile/src/screens/settings/SettingsScreen.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, View, SafeAreaView, ScrollView, TouchableOpacity, Switch, Alert } from 'react-native';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useBiometricAuth } from '@/hooks';
|
||||
import { Card, Button } from '@/components';
|
||||
import { COLORS, FONT_SIZES, SPACING } from '@/constants/theme';
|
||||
import { TIER_FEATURES } from '@/constants/theme';
|
||||
|
||||
export function SettingsScreen() {
|
||||
const { user, logout } = useAuthStore();
|
||||
const { preferences, updatePreferences, isBiometricEnabled } = useSettingsStore();
|
||||
const { enableBiometric, disableBiometric } = useBiometricAuth();
|
||||
|
||||
const handleLogout = () => {
|
||||
Alert.alert(
|
||||
'Logout',
|
||||
'Are you sure you want to logout?',
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Logout',
|
||||
style: 'destructive',
|
||||
onPress: () => logout(),
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const handleBiometricToggle = async (value: boolean) => {
|
||||
if (value) {
|
||||
const success = await enableBiometric();
|
||||
if (!success) {
|
||||
updatePreferences({});
|
||||
}
|
||||
} else {
|
||||
disableBiometric();
|
||||
}
|
||||
};
|
||||
|
||||
const tier = user?.tier || 'free';
|
||||
const features = TIER_FEATURES[tier as keyof typeof TIER_FEATURES];
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Settings</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.content}>
|
||||
<Card title="Account">
|
||||
<View style={styles.profileRow}>
|
||||
<View style={styles.avatar}>
|
||||
<Text style={styles.avatarText}>
|
||||
{user?.firstName?.charAt(0) || 'U'}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.profileInfo}>
|
||||
<Text style={styles.profileName}>
|
||||
{user?.firstName} {user?.lastName}
|
||||
</Text>
|
||||
<Text style={styles.profileEmail}>{user?.email}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<Card title="Subscription">
|
||||
<View style={styles.tierRow}>
|
||||
<Text style={styles.tierLabel}>Current Plan</Text>
|
||||
<View style={[styles.tierBadge, { backgroundColor: COLORS.primary }]}>
|
||||
<Text style={styles.tierBadgeText}>{features.name}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.featuresList}>
|
||||
<FeatureRow label="Spam Protection" available={features.spamProtection} />
|
||||
<FeatureRow label="VoicePrint" available={features.voicePrint} />
|
||||
<FeatureRow label="DarkWatch" available={features.darkWatch} />
|
||||
<FeatureRow
|
||||
label="Max Exposures/Month"
|
||||
value={features.maxExposures === -1 ? 'Unlimited' : `${features.maxExposures}`}
|
||||
/>
|
||||
</View>
|
||||
<Button
|
||||
title="Upgrade Plan"
|
||||
onPress={() =>
|
||||
Alert.alert(
|
||||
'Upgrade Plan',
|
||||
'Billing integration coming soon. Contact support@shieldai.freno.me to upgrade.',
|
||||
[{ text: 'OK' }]
|
||||
)
|
||||
}
|
||||
variant="secondary"
|
||||
fullWidth
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="Notifications">
|
||||
<ToggleRow
|
||||
label="Push Notifications"
|
||||
value={preferences.pushNotifications}
|
||||
onToggle={() => updatePreferences({ pushNotifications: !preferences.pushNotifications })}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="Email Notifications"
|
||||
value={preferences.emailNotifications}
|
||||
onToggle={() => updatePreferences({ emailNotifications: !preferences.emailNotifications })}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="DarkWatch Alerts"
|
||||
value={preferences.darkwatchAlert}
|
||||
onToggle={() => updatePreferences({ darkwatchAlert: !preferences.darkwatchAlert })}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="Spam Blocked Alerts"
|
||||
value={preferences.spamBlocked}
|
||||
onToggle={() => updatePreferences({ spamBlocked: !preferences.spamBlocked })}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="VoicePrint Analysis"
|
||||
value={preferences.voiceprintAnalysis}
|
||||
onToggle={() => updatePreferences({ voiceprintAnalysis: !preferences.voiceprintAnalysis })}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="Security">
|
||||
<ToggleRow
|
||||
label="Biometric Authentication"
|
||||
value={isBiometricEnabled}
|
||||
onToggle={handleBiometricToggle}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<View style={styles.logoutSection}>
|
||||
<Button title="Logout" onPress={handleLogout} variant="danger" fullWidth />
|
||||
</View>
|
||||
|
||||
<View style={styles.version}>
|
||||
<Text style={styles.versionText}>ShieldAI v1.0.0</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToggleRowProps {
|
||||
label: string;
|
||||
value: boolean;
|
||||
onToggle: (value: boolean) => void;
|
||||
}
|
||||
|
||||
function ToggleRow({ label, value, onToggle }: ToggleRowProps) {
|
||||
return (
|
||||
<View style={styles.toggleRow}>
|
||||
<Text style={styles.toggleLabel}>{label}</Text>
|
||||
<Switch
|
||||
value={value}
|
||||
onValueChange={onToggle}
|
||||
trackColor={{ true: COLORS.primary, false: COLORS.border }}
|
||||
thumbColor="#fff"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface FeatureRowProps {
|
||||
label: string;
|
||||
available?: boolean;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
function FeatureRow({ label, available, value }: FeatureRowProps) {
|
||||
return (
|
||||
<View style={styles.featureRow}>
|
||||
<Text style={styles.featureLabel}>{label}</Text>
|
||||
<Text style={styles.featureValue}>
|
||||
{value ?? (available !== undefined ? (available ? '✓' : '✕') : '')}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: COLORS.background,
|
||||
},
|
||||
header: {
|
||||
padding: SPACING.md,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: COLORS.border,
|
||||
},
|
||||
title: {
|
||||
color: COLORS.text,
|
||||
fontSize: FONT_SIZES.xxl,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
profileRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
avatar: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
backgroundColor: COLORS.primary,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: SPACING.md,
|
||||
},
|
||||
avatarText: {
|
||||
color: '#fff',
|
||||
fontSize: FONT_SIZES.xxl,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
profileInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
profileName: {
|
||||
color: COLORS.text,
|
||||
fontSize: FONT_SIZES.lg,
|
||||
fontWeight: '600',
|
||||
},
|
||||
profileEmail: {
|
||||
color: COLORS.textSecondary,
|
||||
fontSize: FONT_SIZES.sm,
|
||||
marginTop: 2,
|
||||
},
|
||||
tierRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: SPACING.md,
|
||||
},
|
||||
tierLabel: {
|
||||
color: COLORS.textSecondary,
|
||||
fontSize: FONT_SIZES.md,
|
||||
},
|
||||
tierBadge: {
|
||||
paddingHorizontal: SPACING.md,
|
||||
paddingVertical: SPACING.xs,
|
||||
borderRadius: 16,
|
||||
},
|
||||
tierBadgeText: {
|
||||
color: '#fff',
|
||||
fontSize: FONT_SIZES.sm,
|
||||
fontWeight: '600',
|
||||
},
|
||||
featuresList: {
|
||||
gap: SPACING.sm,
|
||||
},
|
||||
featureRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: SPACING.xs,
|
||||
},
|
||||
featureLabel: {
|
||||
color: COLORS.textSecondary,
|
||||
fontSize: FONT_SIZES.sm,
|
||||
},
|
||||
featureValue: {
|
||||
color: COLORS.text,
|
||||
fontSize: FONT_SIZES.sm,
|
||||
fontWeight: '500',
|
||||
},
|
||||
toggleRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingVertical: SPACING.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: COLORS.border,
|
||||
},
|
||||
toggleLabel: {
|
||||
color: COLORS.text,
|
||||
fontSize: FONT_SIZES.md,
|
||||
},
|
||||
logoutSection: {
|
||||
paddingHorizontal: SPACING.md,
|
||||
paddingVertical: SPACING.md,
|
||||
},
|
||||
version: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: SPACING.lg,
|
||||
},
|
||||
versionText: {
|
||||
color: COLORS.textMuted,
|
||||
fontSize: FONT_SIZES.xs,
|
||||
},
|
||||
});
|
||||
1
packages/mobile/src/screens/settings/index.ts
Normal file
1
packages/mobile/src/screens/settings/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './SettingsScreen';
|
||||
303
packages/mobile/src/screens/spamshield/SpamShieldScreen.tsx
Normal file
303
packages/mobile/src/screens/spamshield/SpamShieldScreen.tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { StyleSheet, Text, View, SafeAreaView, ScrollView, TouchableOpacity, Alert } from 'react-native';
|
||||
import { useSpamShieldStore } from '@/store/spamShieldStore';
|
||||
import { Card, Button, Input, StatCard, EmptyState } from '@/components';
|
||||
import { COLORS, FONT_SIZES, SPACING, BORDER_RADIUS } from '@/constants/theme';
|
||||
|
||||
type TabType = 'history' | 'whitelist' | 'blacklist';
|
||||
|
||||
export function SpamShieldScreen() {
|
||||
const {
|
||||
callHistory,
|
||||
textHistory,
|
||||
whitelist,
|
||||
blacklist,
|
||||
addToWhitelist,
|
||||
addToBlacklist,
|
||||
removeFromWhitelist,
|
||||
removeFromBlacklist,
|
||||
} = useSpamShieldStore();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabType>('history');
|
||||
const [newNumber, setNewNumber] = useState('');
|
||||
const [newLabel, setNewLabel] = useState('');
|
||||
|
||||
const blockedCount = useMemo(
|
||||
() => callHistory.filter((r) => r.isBlocked).length,
|
||||
[callHistory]
|
||||
);
|
||||
const totalCount = useMemo(
|
||||
() => callHistory.length + textHistory.length,
|
||||
[callHistory, textHistory]
|
||||
);
|
||||
|
||||
const handleAddToList = (list: 'whitelist' | 'blacklist') => {
|
||||
if (!newNumber) return;
|
||||
|
||||
if (list === 'whitelist') {
|
||||
addToWhitelist(newNumber, newLabel || 'Contact');
|
||||
} else {
|
||||
addToBlacklist(newNumber, newLabel || 'Blocked');
|
||||
}
|
||||
|
||||
setNewNumber('');
|
||||
setNewLabel('');
|
||||
};
|
||||
|
||||
const handleRemoveFromList = (list: 'whitelist' | 'blacklist', number: string) => {
|
||||
Alert.alert(
|
||||
`Remove from ${list}`,
|
||||
`Remove ${number} from your ${list}?`,
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Remove',
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
if (list === 'whitelist') removeFromWhitelist(number);
|
||||
else removeFromBlacklist(number);
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>SpamShield</Text>
|
||||
<Text style={styles.subtitle}>Call & text protection</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.content}>
|
||||
<Card>
|
||||
<StatCard title="Blocked Today" value={blockedCount} color={COLORS.success} />
|
||||
<StatCard title="Total Blocked" value={totalCount} color={COLORS.accent} />
|
||||
</Card>
|
||||
|
||||
<View style={styles.tabs}>
|
||||
{(['history', 'whitelist', 'blacklist'] as const).map((tab) => (
|
||||
<TouchableOpacity
|
||||
key={tab}
|
||||
style={[styles.tab, { backgroundColor: activeTab === tab ? COLORS.primary : 'transparent' }]}
|
||||
onPress={() => setActiveTab(tab)}
|
||||
>
|
||||
<Text style={[
|
||||
styles.tabText,
|
||||
{ color: activeTab === tab ? '#fff' : COLORS.textSecondary }
|
||||
]}>
|
||||
{tab.charAt(0).toUpperCase() + tab.slice(1)}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{activeTab === 'history' && (
|
||||
<Card title="Recent Activity">
|
||||
{(callHistory.length === 0 && textHistory.length === 0) ? (
|
||||
<EmptyState title="No call or text history" message="Blocked spam will appear here" />
|
||||
) : (
|
||||
<>
|
||||
{callHistory.map((record) => (
|
||||
<View key={record.id} style={styles.historyItem}>
|
||||
<View style={styles.historyItemLeft}>
|
||||
<Text style={styles.historyType}>{record.type.toUpperCase()}</Text>
|
||||
<Text style={styles.historyNumber}>{record.phoneNumber}</Text>
|
||||
</View>
|
||||
<View style={[
|
||||
styles.historyBadge,
|
||||
{ backgroundColor: record.isBlocked ? COLORS.success : COLORS.warning }
|
||||
]}>
|
||||
<Text style={styles.historyBadgeText}>
|
||||
{record.isBlocked ? 'BLOCKED' : 'ALLOWED'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'whitelist' && (
|
||||
<Card title="Whitelist">
|
||||
<View style={styles.addToList}>
|
||||
<Input
|
||||
placeholder="Phone number"
|
||||
value={newNumber}
|
||||
onChangeText={setNewNumber}
|
||||
keyboardType="phone-pad"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Label (optional)"
|
||||
value={newLabel}
|
||||
onChangeText={setNewLabel}
|
||||
/>
|
||||
<Button
|
||||
title="Add to Whitelist"
|
||||
onPress={() => handleAddToList('whitelist')}
|
||||
variant="primary"
|
||||
fullWidth
|
||||
/>
|
||||
</View>
|
||||
|
||||
{whitelist.length === 0 ? (
|
||||
<EmptyState title="Whitelist is empty" message="Add trusted contacts here" />
|
||||
) : (
|
||||
whitelist.map((item) => (
|
||||
<View key={item.number} style={styles.listItem}>
|
||||
<View style={styles.listItemLeft}>
|
||||
<Text style={styles.listItemNumber}>{item.number}</Text>
|
||||
<Text style={styles.listItemLabel}>{item.label}</Text>
|
||||
</View>
|
||||
<TouchableOpacity onPress={() => handleRemoveFromList('whitelist', item.number)}>
|
||||
<Text style={styles.removeText}>Remove</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'blacklist' && (
|
||||
<Card title="Blacklist">
|
||||
<View style={styles.addToList}>
|
||||
<Input
|
||||
placeholder="Phone number"
|
||||
value={newNumber}
|
||||
onChangeText={setNewNumber}
|
||||
keyboardType="phone-pad"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Label (optional)"
|
||||
value={newLabel}
|
||||
onChangeText={setNewLabel}
|
||||
/>
|
||||
<Button
|
||||
title="Add to Blacklist"
|
||||
onPress={() => handleAddToList('blacklist')}
|
||||
variant="danger"
|
||||
fullWidth
|
||||
/>
|
||||
</View>
|
||||
|
||||
{blacklist.length === 0 ? (
|
||||
<EmptyState title="Blacklist is empty" message="Add numbers to block here" />
|
||||
) : (
|
||||
blacklist.map((item) => (
|
||||
<View key={item.number} style={styles.listItem}>
|
||||
<View style={styles.listItemLeft}>
|
||||
<Text style={styles.listItemNumber}>{item.number}</Text>
|
||||
<Text style={styles.listItemLabel}>{item.label}</Text>
|
||||
</View>
|
||||
<TouchableOpacity onPress={() => handleRemoveFromList('blacklist', item.number)}>
|
||||
<Text style={styles.removeText}>Remove</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: COLORS.background,
|
||||
},
|
||||
header: {
|
||||
padding: SPACING.md,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: COLORS.border,
|
||||
},
|
||||
title: {
|
||||
color: COLORS.text,
|
||||
fontSize: FONT_SIZES.xxl,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
subtitle: {
|
||||
color: COLORS.textSecondary,
|
||||
fontSize: FONT_SIZES.sm,
|
||||
marginTop: SPACING.xs,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
tabs: {
|
||||
flexDirection: 'row',
|
||||
marginHorizontal: SPACING.md,
|
||||
marginVertical: SPACING.sm,
|
||||
backgroundColor: COLORS.card,
|
||||
borderRadius: BORDER_RADIUS.md,
|
||||
padding: 4,
|
||||
},
|
||||
tab: {
|
||||
flex: 1,
|
||||
paddingVertical: SPACING.sm,
|
||||
borderRadius: BORDER_RADIUS.sm,
|
||||
alignItems: 'center',
|
||||
},
|
||||
tabText: {
|
||||
fontSize: FONT_SIZES.sm,
|
||||
fontWeight: '600',
|
||||
},
|
||||
historyItem: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingVertical: SPACING.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: COLORS.border,
|
||||
},
|
||||
historyItemLeft: {
|
||||
flex: 1,
|
||||
},
|
||||
historyType: {
|
||||
color: COLORS.textSecondary,
|
||||
fontSize: FONT_SIZES.xs,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
historyNumber: {
|
||||
color: COLORS.text,
|
||||
fontSize: FONT_SIZES.md,
|
||||
},
|
||||
historyBadge: {
|
||||
paddingHorizontal: SPACING.sm,
|
||||
paddingVertical: 4,
|
||||
borderRadius: BORDER_RADIUS.round,
|
||||
},
|
||||
historyBadgeText: {
|
||||
color: '#fff',
|
||||
fontSize: FONT_SIZES.xs,
|
||||
fontWeight: '600',
|
||||
},
|
||||
addToList: {
|
||||
marginBottom: SPACING.md,
|
||||
},
|
||||
listItem: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingVertical: SPACING.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: COLORS.border,
|
||||
},
|
||||
listItemLeft: {
|
||||
flex: 1,
|
||||
},
|
||||
listItemNumber: {
|
||||
color: COLORS.text,
|
||||
fontSize: FONT_SIZES.md,
|
||||
},
|
||||
listItemLabel: {
|
||||
color: COLORS.textSecondary,
|
||||
fontSize: FONT_SIZES.sm,
|
||||
},
|
||||
removeText: {
|
||||
color: COLORS.danger,
|
||||
fontSize: FONT_SIZES.sm,
|
||||
},
|
||||
});
|
||||
1
packages/mobile/src/screens/spamshield/index.ts
Normal file
1
packages/mobile/src/screens/spamshield/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './SpamShieldScreen';
|
||||
316
packages/mobile/src/screens/voiceprint/VoicePrintScreen.tsx
Normal file
316
packages/mobile/src/screens/voiceprint/VoicePrintScreen.tsx
Normal file
@@ -0,0 +1,316 @@
|
||||
import React, { useState } from 'react';
|
||||
import { StyleSheet, Text, View, SafeAreaView, ScrollView, TouchableOpacity, Alert, Modal } from 'react-native';
|
||||
import { useVoicePrintStore } from '@/store/voicePrintStore';
|
||||
import { Card, Button, Input, EmptyState } from '@/components';
|
||||
import { COLORS, FONT_SIZES, SPACING, BORDER_RADIUS } from '@/constants/theme';
|
||||
|
||||
export function VoicePrintScreen() {
|
||||
const { profiles, analyses, isRecording, addProfile, removeProfile, startRecording, stopRecording } = useVoicePrintStore();
|
||||
const [showEnrollModal, setShowEnrollModal] = useState(false);
|
||||
const [profileName, setProfileName] = useState('');
|
||||
const [relationship, setRelationship] = useState('');
|
||||
|
||||
const handleEnroll = async () => {
|
||||
if (!profileName || !relationship) return;
|
||||
|
||||
await addProfile(profileName, relationship);
|
||||
setProfileName('');
|
||||
setRelationship('');
|
||||
setShowEnrollModal(false);
|
||||
};
|
||||
|
||||
const handleRemoveProfile = (id: string, name: string) => {
|
||||
Alert.alert(
|
||||
'Remove Voice Profile',
|
||||
`Remove voice profile for "${name}"? This cannot be undone.`,
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Remove',
|
||||
style: 'destructive',
|
||||
onPress: () => removeProfile(id),
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const handleRecording = () => {
|
||||
if (isRecording) {
|
||||
stopRecording();
|
||||
} else {
|
||||
startRecording();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>VoicePrint</Text>
|
||||
<Text style={styles.subtitle}>Voice authentication & protection</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.content}>
|
||||
<Card title="Voice Profiles">
|
||||
<Button
|
||||
title="+ Enroll Family Member"
|
||||
onPress={() => setShowEnrollModal(true)}
|
||||
variant="secondary"
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
{profiles.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No voice profiles"
|
||||
message="Enroll family members to protect against voice impersonation"
|
||||
/>
|
||||
) : (
|
||||
profiles.map((profile) => (
|
||||
<View key={profile.id} style={styles.profileItem}>
|
||||
<View style={styles.profileItemLeft}>
|
||||
<View style={styles.avatar}>
|
||||
<Text style={styles.avatarText}>
|
||||
{profile.name.charAt(0).toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.profileInfo}>
|
||||
<Text style={styles.profileName}>{profile.name}</Text>
|
||||
<Text style={styles.profileRelationship}>{profile.relationship}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.profileItemRight}>
|
||||
<Text style={styles.confidence}>
|
||||
{profile.confidence}% match
|
||||
</Text>
|
||||
<TouchableOpacity onPress={() => handleRemoveProfile(profile.id, profile.name)}>
|
||||
<Text style={styles.removeText}>Remove</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title="Voice Analysis">
|
||||
<View style={styles.recordingSection}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.recordButton,
|
||||
{ backgroundColor: isRecording ? COLORS.danger : COLORS.primary },
|
||||
]}
|
||||
onPress={handleRecording}
|
||||
>
|
||||
<View style={[styles.recordDot, { backgroundColor: isRecording ? '#fff' : COLORS.card }]} />
|
||||
<Text style={styles.recordButtonText}>
|
||||
{isRecording ? 'Stop Recording' : 'Test Voice Match'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.recordingHint}>
|
||||
{isRecording
|
||||
? 'Recording... Speak clearly into the microphone'
|
||||
: 'Tap to test voice matching against enrolled profiles'
|
||||
}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{analyses.length === 0 ? (
|
||||
<EmptyState title="No analysis results" message="Record voice samples to see match results" />
|
||||
) : (
|
||||
analyses.map((analysis) => (
|
||||
<View key={analysis.id} style={styles.analysisItem}>
|
||||
<View style={styles.analysisLeft}>
|
||||
<Text style={styles.analysisTime}>
|
||||
{new Date(analysis.timestamp).toLocaleString()}
|
||||
</Text>
|
||||
<Text style={[
|
||||
styles.analysisResult,
|
||||
{ color: analysis.isMatch ? COLORS.success : COLORS.danger }
|
||||
]}>
|
||||
{analysis.isMatch ? 'Match' : 'No Match'} - {analysis.confidence}%
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</Card>
|
||||
</ScrollView>
|
||||
|
||||
<Modal visible={showEnrollModal} animationType="slide" transparent>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={styles.modalContent}>
|
||||
<Text style={styles.modalTitle}>Enroll Family Member</Text>
|
||||
|
||||
<Input
|
||||
label="Name"
|
||||
placeholder="e.g., Mom"
|
||||
value={profileName}
|
||||
onChangeText={setProfileName}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Relationship"
|
||||
placeholder="e.g., Mother"
|
||||
value={relationship}
|
||||
onChangeText={setRelationship}
|
||||
/>
|
||||
|
||||
<View style={styles.modalActions}>
|
||||
<Button
|
||||
title="Cancel"
|
||||
onPress={() => setShowEnrollModal(false)}
|
||||
variant="ghost"
|
||||
/>
|
||||
<Button
|
||||
title="Enroll"
|
||||
onPress={handleEnroll}
|
||||
variant="primary"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: COLORS.background,
|
||||
},
|
||||
header: {
|
||||
padding: SPACING.md,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: COLORS.border,
|
||||
},
|
||||
title: {
|
||||
color: COLORS.text,
|
||||
fontSize: FONT_SIZES.xxl,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
subtitle: {
|
||||
color: COLORS.textSecondary,
|
||||
fontSize: FONT_SIZES.sm,
|
||||
marginTop: SPACING.xs,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
profileItem: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingVertical: SPACING.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: COLORS.border,
|
||||
},
|
||||
profileItemLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
},
|
||||
avatar: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: COLORS.primary,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: SPACING.sm,
|
||||
},
|
||||
avatarText: {
|
||||
color: '#fff',
|
||||
fontSize: FONT_SIZES.md,
|
||||
fontWeight: '600',
|
||||
},
|
||||
profileInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
profileName: {
|
||||
color: COLORS.text,
|
||||
fontSize: FONT_SIZES.md,
|
||||
fontWeight: '500',
|
||||
},
|
||||
profileRelationship: {
|
||||
color: COLORS.textSecondary,
|
||||
fontSize: FONT_SIZES.sm,
|
||||
},
|
||||
profileItemRight: {
|
||||
alignItems: 'flex-end',
|
||||
gap: SPACING.xs,
|
||||
},
|
||||
confidence: {
|
||||
color: COLORS.accent,
|
||||
fontSize: FONT_SIZES.sm,
|
||||
},
|
||||
removeText: {
|
||||
color: COLORS.danger,
|
||||
fontSize: FONT_SIZES.sm,
|
||||
},
|
||||
recordingSection: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: SPACING.md,
|
||||
},
|
||||
recordButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: SPACING.sm,
|
||||
paddingVertical: SPACING.md,
|
||||
paddingHorizontal: SPACING.xl,
|
||||
borderRadius: BORDER_RADIUS.round,
|
||||
},
|
||||
recordDot: {
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: 6,
|
||||
},
|
||||
recordButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: FONT_SIZES.md,
|
||||
fontWeight: '600',
|
||||
},
|
||||
recordingHint: {
|
||||
color: COLORS.textMuted,
|
||||
fontSize: FONT_SIZES.sm,
|
||||
marginTop: SPACING.sm,
|
||||
textAlign: 'center',
|
||||
},
|
||||
analysisItem: {
|
||||
paddingVertical: SPACING.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: COLORS.border,
|
||||
},
|
||||
analysisLeft: {
|
||||
gap: 4,
|
||||
},
|
||||
analysisTime: {
|
||||
color: COLORS.textMuted,
|
||||
fontSize: FONT_SIZES.xs,
|
||||
},
|
||||
analysisResult: {
|
||||
fontSize: FONT_SIZES.sm,
|
||||
fontWeight: '500',
|
||||
},
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
modalContent: {
|
||||
backgroundColor: COLORS.backgroundLight,
|
||||
borderTopLeftRadius: BORDER_RADIUS.xl,
|
||||
borderTopRightRadius: BORDER_RADIUS.xl,
|
||||
padding: SPACING.lg,
|
||||
paddingBottom: SPACING.xxl,
|
||||
},
|
||||
modalTitle: {
|
||||
color: COLORS.text,
|
||||
fontSize: FONT_SIZES.xl,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: SPACING.md,
|
||||
},
|
||||
modalActions: {
|
||||
flexDirection: 'row',
|
||||
gap: SPACING.md,
|
||||
marginTop: SPACING.md,
|
||||
},
|
||||
});
|
||||
1
packages/mobile/src/screens/voiceprint/index.ts
Normal file
1
packages/mobile/src/screens/voiceprint/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './VoicePrintScreen';
|
||||
13
packages/mobile/src/services/api.ts
Normal file
13
packages/mobile/src/services/api.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createApiClient } from '@shieldai/mobile-api-client';
|
||||
import { API_URL } from '@/constants/theme';
|
||||
|
||||
// TODO SEC-007: Add certificate pinning for production builds.
|
||||
// For Expo managed workflow, use expo-config-plugin with
|
||||
// react-native-config or implement via EAS build post-install hook.
|
||||
// Reference: https://docs.expo.dev/guides/using-network-security/
|
||||
|
||||
createApiClient({
|
||||
baseURL: API_URL,
|
||||
timeout: 30000,
|
||||
debug: __DEV__,
|
||||
});
|
||||
27
packages/mobile/src/services/encryptedStorage.ts
Normal file
27
packages/mobile/src/services/encryptedStorage.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import type { StateStorage } from 'zustand/middleware';
|
||||
import { createJSONStorage } from 'zustand/middleware';
|
||||
import { encryptText, decryptText } from './secureStorage';
|
||||
|
||||
function createEncryptedAsyncStorage(): StateStorage {
|
||||
return {
|
||||
getItem: async (name: string): Promise<string | null> => {
|
||||
const encrypted = await AsyncStorage.getItem(name);
|
||||
if (encrypted === null) return null;
|
||||
try {
|
||||
return await decryptText(encrypted);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
setItem: async (name: string, value: string): Promise<void> => {
|
||||
const encrypted = await encryptText(value);
|
||||
await AsyncStorage.setItem(name, encrypted);
|
||||
},
|
||||
removeItem: async (name: string): Promise<void> => {
|
||||
await AsyncStorage.removeItem(name);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const encryptedStorage = createJSONStorage(() => createEncryptedAsyncStorage());
|
||||
2
packages/mobile/src/services/index.ts
Normal file
2
packages/mobile/src/services/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './api';
|
||||
export * from './secureStorage';
|
||||
75
packages/mobile/src/services/secureStorage.ts
Normal file
75
packages/mobile/src/services/secureStorage.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import { randomUUID } from 'expo-crypto';
|
||||
|
||||
const ACCESS_TOKEN_KEY = '@shieldai_access_token';
|
||||
const REFRESH_TOKEN_KEY = '@shieldai_refresh_token';
|
||||
|
||||
export async function getAccessToken(): Promise<string | null> {
|
||||
return SecureStore.getItemAsync(ACCESS_TOKEN_KEY);
|
||||
}
|
||||
|
||||
export async function setAccessToken(token: string): Promise<void> {
|
||||
await SecureStore.setItemAsync(ACCESS_TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
export async function getRefreshToken(): Promise<string | null> {
|
||||
return SecureStore.getItemAsync(REFRESH_TOKEN_KEY);
|
||||
}
|
||||
|
||||
export async function setRefreshToken(token: string): Promise<void> {
|
||||
await SecureStore.setItemAsync(REFRESH_TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
export async function clearTokens(): Promise<void> {
|
||||
try {
|
||||
await SecureStore.deleteItemAsync(ACCESS_TOKEN_KEY);
|
||||
} catch { /* may not exist */ }
|
||||
try {
|
||||
await SecureStore.deleteItemAsync(REFRESH_TOKEN_KEY);
|
||||
} catch { /* may not exist */ }
|
||||
}
|
||||
|
||||
// XOR cipher for PII in AsyncStorage.
|
||||
// Key stored in SecureStore (Keychain/Keystore).
|
||||
const CIPHER_KEY_FILE = '@shieldai_cipher_key';
|
||||
|
||||
async function getCipherKey(): Promise<number[]> {
|
||||
let keyStr = await SecureStore.getItemAsync(CIPHER_KEY_FILE);
|
||||
if (!keyStr) {
|
||||
keyStr = randomUUID();
|
||||
await SecureStore.setItemAsync(CIPHER_KEY_FILE, keyStr);
|
||||
}
|
||||
return keyStr.split('').map((c) => c.charCodeAt(0));
|
||||
}
|
||||
|
||||
function xorEncode(str: string, key: number[]): string {
|
||||
const codePoints: number[] = [];
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
codePoints.push(str.charCodeAt(i) ^ key[i % key.length]);
|
||||
}
|
||||
// Encode as base64 using Buffer (available in RN)
|
||||
return Buffer.from(codePoints).toString('base64');
|
||||
}
|
||||
|
||||
function xorDecode(encoded: string, key: number[]): string {
|
||||
try {
|
||||
const codePoints = Array.from(Buffer.from(encoded, 'base64'));
|
||||
const chars: string[] = [];
|
||||
for (let i = 0; i < codePoints.length; i++) {
|
||||
chars.push(String.fromCharCode(codePoints[i] ^ key[i % key.length]));
|
||||
}
|
||||
return chars.join('');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export async function encryptText(plain: string): Promise<string> {
|
||||
const key = await getCipherKey();
|
||||
return xorEncode(plain, key);
|
||||
}
|
||||
|
||||
export async function decryptText(encrypted: string): Promise<string> {
|
||||
const key = await getCipherKey();
|
||||
return xorDecode(encrypted, key);
|
||||
}
|
||||
152
packages/mobile/src/store/authStore.ts
Normal file
152
packages/mobile/src/store/authStore.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { User } from '@/types';
|
||||
import { authService } from '@shieldai/mobile-api-client';
|
||||
import { AUTH_STORAGE_KEY } from '@/constants';
|
||||
import {
|
||||
getAccessToken,
|
||||
setAccessToken,
|
||||
getRefreshToken,
|
||||
setRefreshToken,
|
||||
clearTokens,
|
||||
} from '@/services/secureStorage';
|
||||
import { encryptedStorage } from '@/services/encryptedStorage';
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
register: (email: string, password: string, firstName: string, lastName: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
clearError: () => void;
|
||||
setUser: (user: User | null) => void;
|
||||
refreshSession: () => Promise<boolean>;
|
||||
hydrateTokens: () => Promise<void>;
|
||||
}
|
||||
|
||||
const toAppUser = (apiUser: any): User => ({
|
||||
id: apiUser.id,
|
||||
email: apiUser.email,
|
||||
firstName: apiUser.firstName || '',
|
||||
lastName: apiUser.lastName || '',
|
||||
tier: (apiUser.tier as User['tier']) || 'free',
|
||||
createdAt: apiUser.createdAt || new Date().toISOString(),
|
||||
});
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
login: async (email: string, password: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const { user: apiUser, tokens } = await authService.login({ email, password });
|
||||
await setAccessToken(tokens.accessToken);
|
||||
if (tokens.refreshToken) {
|
||||
await setRefreshToken(tokens.refreshToken);
|
||||
}
|
||||
set({
|
||||
user: toAppUser(apiUser),
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
});
|
||||
} catch (err: any) {
|
||||
set({
|
||||
error: err.message || 'Login failed',
|
||||
isLoading: false,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
register: async (email: string, password: string, firstName: string, lastName: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const { user: apiUser, tokens } = await authService.register({
|
||||
email,
|
||||
password,
|
||||
firstName,
|
||||
lastName,
|
||||
});
|
||||
await setAccessToken(tokens.accessToken);
|
||||
if (tokens.refreshToken) {
|
||||
await setRefreshToken(tokens.refreshToken);
|
||||
}
|
||||
set({
|
||||
user: toAppUser(apiUser),
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
});
|
||||
} catch (err: any) {
|
||||
set({
|
||||
error: err.message || 'Registration failed',
|
||||
isLoading: false,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
try {
|
||||
await authService.logout();
|
||||
} finally {
|
||||
await clearTokens();
|
||||
set({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
refreshSession: async (): Promise<boolean> => {
|
||||
const refreshToken = await getRefreshToken();
|
||||
if (!refreshToken) return false;
|
||||
try {
|
||||
const newTokens = await authService.refreshToken();
|
||||
await setAccessToken(newTokens.accessToken);
|
||||
if (newTokens.refreshToken) {
|
||||
await setRefreshToken(newTokens.refreshToken);
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
await clearTokens();
|
||||
set({ user: null, isAuthenticated: false });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
hydrateTokens: async () => {
|
||||
const token = await getAccessToken();
|
||||
if (token) {
|
||||
try {
|
||||
const user = await authService.getCurrentUser();
|
||||
if (user) {
|
||||
set({ user: toAppUser(user), isAuthenticated: true });
|
||||
}
|
||||
} catch {
|
||||
await clearTokens();
|
||||
set({ user: null, isAuthenticated: false });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
clearError: () => set({ error: null }),
|
||||
setUser: (user) => set({ user, isAuthenticated: !!user }),
|
||||
}),
|
||||
{
|
||||
name: AUTH_STORAGE_KEY,
|
||||
storage: encryptedStorage,
|
||||
partialize: (state) => ({
|
||||
user: state.user,
|
||||
isAuthenticated: state.isAuthenticated,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
68
packages/mobile/src/store/darkWatchStore.ts
Normal file
68
packages/mobile/src/store/darkWatchStore.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { randomUUID } from 'expo-crypto';
|
||||
import type { WatchListItem, Exposure } from '@/types';
|
||||
import { encryptedStorage } from '@/services/encryptedStorage';
|
||||
|
||||
/**
|
||||
* TODO: Wire store operations to @shieldai/mobile-api-client for production.
|
||||
* Current implementation is local-only (AsyncStorage) for offline-first MVP.
|
||||
*/
|
||||
|
||||
interface DarkWatchState {
|
||||
watchList: WatchListItem[];
|
||||
exposures: Exposure[];
|
||||
isLoading: boolean;
|
||||
addWatchItem: (item: Omit<WatchListItem, 'id' | 'lastChecked'>) => Promise<void>;
|
||||
removeWatchItem: (id: string) => void;
|
||||
toggleAlert: (id: string) => void;
|
||||
refreshExposures: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useDarkWatchStore = create<DarkWatchState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
watchList: [],
|
||||
exposures: [],
|
||||
isLoading: false,
|
||||
|
||||
addWatchItem: async (item) => {
|
||||
const newItem: WatchListItem = {
|
||||
...item,
|
||||
id: randomUUID(),
|
||||
lastChecked: new Date().toISOString(),
|
||||
};
|
||||
set((state) => ({ watchList: [...state.watchList, newItem] }));
|
||||
},
|
||||
|
||||
removeWatchItem: (id) => {
|
||||
set((state) => ({
|
||||
watchList: state.watchList.filter((item) => item.id !== id),
|
||||
}));
|
||||
},
|
||||
|
||||
toggleAlert: (id) => {
|
||||
set((state) => ({
|
||||
watchList: state.watchList.map((item) =>
|
||||
item.id === id ? { ...item, alertEnabled: !item.alertEnabled } : item
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
refreshExposures: async () => {
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
// TODO: Wire to @shieldai/mobile-api-client for production
|
||||
set({ isLoading: false });
|
||||
} catch (error) {
|
||||
console.error('[DarkWatch] Failed to refresh exposures:', error);
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: '@shieldai_darkwatch',
|
||||
storage: encryptedStorage,
|
||||
}
|
||||
)
|
||||
);
|
||||
46
packages/mobile/src/store/dashboardStore.ts
Normal file
46
packages/mobile/src/store/dashboardStore.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import type { DashboardData } from '@/types';
|
||||
|
||||
interface DashboardState {
|
||||
data: DashboardData | null;
|
||||
isLoading: boolean;
|
||||
lastUpdated: string | null;
|
||||
refreshDashboard: () => Promise<void>;
|
||||
setData: (data: DashboardData) => void;
|
||||
}
|
||||
|
||||
export const useDashboardStore = create<DashboardState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
lastUpdated: null,
|
||||
|
||||
refreshDashboard: async () => {
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
const mockData: DashboardData = {
|
||||
exposureSummary: { total: 0, unresolved: 0, critical: 0 },
|
||||
spamStats: { blockedToday: 0, blockedTotal: 0, spamScore: 0 },
|
||||
voiceProtectionStatus: { isMonitoring: false, profilesEnrolled: 0, lastAnalysis: '' },
|
||||
};
|
||||
set({
|
||||
data: mockData,
|
||||
isLoading: false,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
});
|
||||
} catch {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
setData: (data) => set({ data, lastUpdated: new Date().toISOString() }),
|
||||
}),
|
||||
{
|
||||
name: '@shieldai_dashboard',
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
}
|
||||
)
|
||||
);
|
||||
45
packages/mobile/src/store/settingsStore.ts
Normal file
45
packages/mobile/src/store/settingsStore.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { NotificationPreference } from '@/types';
|
||||
import { encryptedStorage } from '@/services/encryptedStorage';
|
||||
|
||||
/**
|
||||
* TODO: Wire updatePreferences to notificationService.updatePreferences() for production.
|
||||
* Current implementation is local-only (AsyncStorage) for offline-first MVP.
|
||||
*/
|
||||
|
||||
interface SettingsState {
|
||||
preferences: NotificationPreference;
|
||||
isBiometricEnabled: boolean;
|
||||
updatePreferences: (prefs: Partial<NotificationPreference>) => void;
|
||||
toggleBiometric: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
const defaultPreferences: NotificationPreference = {
|
||||
emailNotifications: true,
|
||||
pushNotifications: true,
|
||||
darkwatchAlert: true,
|
||||
spamBlocked: true,
|
||||
voiceprintAnalysis: true,
|
||||
};
|
||||
|
||||
export const useSettingsStore = create<SettingsState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
preferences: defaultPreferences,
|
||||
isBiometricEnabled: false,
|
||||
|
||||
updatePreferences: (prefs) => {
|
||||
set((state) => ({
|
||||
preferences: { ...state.preferences, ...prefs },
|
||||
}));
|
||||
},
|
||||
|
||||
toggleBiometric: (enabled) => set({ isBiometricEnabled: enabled }),
|
||||
}),
|
||||
{
|
||||
name: '@shieldai_settings',
|
||||
storage: encryptedStorage,
|
||||
}
|
||||
)
|
||||
);
|
||||
63
packages/mobile/src/store/spamShieldStore.ts
Normal file
63
packages/mobile/src/store/spamShieldStore.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { SpamRecord } from '@/types';
|
||||
import { encryptedStorage } from '@/services/encryptedStorage';
|
||||
|
||||
/**
|
||||
* TODO: Wire store operations to @shieldai/mobile-api-client for production.
|
||||
* Current implementation is local-only (AsyncStorage) for offline-first MVP.
|
||||
*/
|
||||
|
||||
type PhoneList = { number: string; label: string }[];
|
||||
|
||||
interface SpamShieldState {
|
||||
callHistory: SpamRecord[];
|
||||
textHistory: SpamRecord[];
|
||||
whitelist: PhoneList;
|
||||
blacklist: PhoneList;
|
||||
isLoading: false;
|
||||
addToWhitelist: (number: string, label: string) => void;
|
||||
addToBlacklist: (number: string, label: string) => void;
|
||||
removeFromWhitelist: (number: string) => void;
|
||||
removeFromBlacklist: (number: string) => void;
|
||||
}
|
||||
|
||||
export const useSpamShieldStore = create<SpamShieldState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
callHistory: [],
|
||||
textHistory: [],
|
||||
whitelist: [],
|
||||
blacklist: [],
|
||||
isLoading: false,
|
||||
|
||||
addToWhitelist: (number, label) => {
|
||||
set((state) => ({
|
||||
whitelist: [...state.whitelist, { number, label }],
|
||||
}));
|
||||
},
|
||||
|
||||
addToBlacklist: (number, label) => {
|
||||
set((state) => ({
|
||||
blacklist: [...state.blacklist, { number, label }],
|
||||
}));
|
||||
},
|
||||
|
||||
removeFromWhitelist: (number) => {
|
||||
set((state) => ({
|
||||
whitelist: state.whitelist.filter((item) => item.number !== number),
|
||||
}));
|
||||
},
|
||||
|
||||
removeFromBlacklist: (number) => {
|
||||
set((state) => ({
|
||||
blacklist: state.blacklist.filter((item) => item.number !== number),
|
||||
}));
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: '@shieldai_spamshield',
|
||||
storage: encryptedStorage,
|
||||
}
|
||||
)
|
||||
);
|
||||
56
packages/mobile/src/store/voicePrintStore.ts
Normal file
56
packages/mobile/src/store/voicePrintStore.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { randomUUID } from 'expo-crypto';
|
||||
import type { VoiceProfile, VoiceAnalysis } from '@/types';
|
||||
import { encryptedStorage } from '@/services/encryptedStorage';
|
||||
|
||||
/**
|
||||
* TODO: Wire store operations to @shieldai/mobile-api-client for production.
|
||||
* Current implementation is local-only (AsyncStorage) for offline-first MVP.
|
||||
*/
|
||||
|
||||
interface VoicePrintState {
|
||||
profiles: VoiceProfile[];
|
||||
analyses: VoiceAnalysis[];
|
||||
isRecording: boolean;
|
||||
isLoading: boolean;
|
||||
addProfile: (name: string, relationship: string) => Promise<void>;
|
||||
removeProfile: (id: string) => void;
|
||||
startRecording: () => void;
|
||||
stopRecording: () => void;
|
||||
}
|
||||
|
||||
export const useVoicePrintStore = create<VoicePrintState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
profiles: [],
|
||||
analyses: [],
|
||||
isRecording: false,
|
||||
isLoading: false,
|
||||
|
||||
addProfile: async (name: string, relationship: string) => {
|
||||
const newProfile: VoiceProfile = {
|
||||
id: randomUUID(),
|
||||
name,
|
||||
relationship,
|
||||
enrolledAt: new Date().toISOString(),
|
||||
confidence: 0,
|
||||
};
|
||||
set((state) => ({ profiles: [...state.profiles, newProfile] }));
|
||||
},
|
||||
|
||||
removeProfile: (id) => {
|
||||
set((state) => ({
|
||||
profiles: state.profiles.filter((p) => p.id !== id),
|
||||
}));
|
||||
},
|
||||
|
||||
startRecording: () => set({ isRecording: true }),
|
||||
stopRecording: () => set({ isRecording: false }),
|
||||
}),
|
||||
{
|
||||
name: '@shieldai_voiceprint',
|
||||
storage: encryptedStorage,
|
||||
}
|
||||
)
|
||||
);
|
||||
79
packages/mobile/src/types/index.ts
Normal file
79
packages/mobile/src/types/index.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
export type Tier = 'free' | 'basic' | 'premium' | 'enterprise';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
tier: Tier;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Exposure {
|
||||
id: string;
|
||||
source: string;
|
||||
data: string;
|
||||
severity: 'low' | 'medium' | 'high' | 'critical';
|
||||
discoveredAt: string;
|
||||
isResolved: boolean;
|
||||
}
|
||||
|
||||
export interface SpamRecord {
|
||||
id: string;
|
||||
type: 'call' | 'text';
|
||||
phoneNumber: string;
|
||||
timestamp: string;
|
||||
isBlocked: boolean;
|
||||
spamScore: number;
|
||||
}
|
||||
|
||||
export interface WatchListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
entityType: 'person' | 'email' | 'phone' | 'address';
|
||||
value: string;
|
||||
alertEnabled: boolean;
|
||||
lastChecked: string;
|
||||
}
|
||||
|
||||
export interface VoiceProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
relationship: string;
|
||||
enrolledAt: string;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export interface VoiceAnalysis {
|
||||
id: string;
|
||||
profileId: string;
|
||||
isMatch: boolean;
|
||||
confidence: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface NotificationPreference {
|
||||
emailNotifications: boolean;
|
||||
pushNotifications: boolean;
|
||||
darkwatchAlert: boolean;
|
||||
spamBlocked: boolean;
|
||||
voiceprintAnalysis: boolean;
|
||||
}
|
||||
|
||||
export interface DashboardData {
|
||||
exposureSummary: {
|
||||
total: number;
|
||||
unresolved: number;
|
||||
critical: number;
|
||||
};
|
||||
spamStats: {
|
||||
blockedToday: number;
|
||||
blockedTotal: number;
|
||||
spamScore: number;
|
||||
};
|
||||
voiceProtectionStatus: {
|
||||
isMonitoring: boolean;
|
||||
profilesEnrolled: number;
|
||||
lastAnalysis: string;
|
||||
};
|
||||
}
|
||||
33
packages/mobile/tsconfig.json
Normal file
33
packages/mobile/tsconfig.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"jsx": "react-native",
|
||||
"target": "esnext",
|
||||
"module": "esnext",
|
||||
"lib": ["es2019"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"moduleResolution": "node",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"incremental": true,
|
||||
"types": ["react-native", "node"]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*",
|
||||
"App.tsx"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"build",
|
||||
".expo"
|
||||
]
|
||||
}
|
||||
1
packages/mobile/tsconfig.tsbuildinfo
Normal file
1
packages/mobile/tsconfig.tsbuildinfo
Normal file
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();
|
||||
});
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
if (reportType === 'ANNUAL_PREMIUM') {
|
||||
if (reportType === 'ANNUAL_PREMIUM' || reportType === 'WEEKLY_DIGEST') {
|
||||
payload.homeTitleStats = await collectHomeTitleStats(
|
||||
subscriptionId,
|
||||
periodStart,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user