mirror of
https://github.com/vernu/textbee.git
synced 2026-09-03 03:29:58 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1281d4ec1 | ||
|
|
46fa5cda2e | ||
|
|
709f05f33d | ||
|
|
844a815813 | ||
|
|
b8d277a563 | ||
|
|
d335f76088 | ||
|
|
42abcec94b | ||
|
|
014ae354c6 | ||
|
|
2692d4a154 | ||
|
|
1b24cd9754 | ||
|
|
e6ad6a18dc | ||
|
|
d51693d26d | ||
|
|
07f80fdf84 | ||
|
|
b762c6e2ef | ||
|
|
c1952d2030 | ||
|
|
ebc90d8105 | ||
|
|
fd0d86efab | ||
|
|
32b463a10c | ||
|
|
ed55406687 | ||
|
|
b6dde1a096 | ||
|
|
312e3a4fd4 | ||
|
|
3939502db7 | ||
|
|
9ae9f5978b | ||
|
|
58e6c73a72 | ||
|
|
f1c8530257 | ||
|
|
ede8e4c210 | ||
|
|
a6df612bfa | ||
|
|
4b4f3bace9 | ||
|
|
8a42b95e8f | ||
|
|
e0e6be9379 | ||
|
|
0665aa5432 | ||
|
|
dc8ca9d9a7 | ||
|
|
541f32406e | ||
|
|
141b8b334a | ||
|
|
f9fed4d3d8 | ||
|
|
4ef65b08ff | ||
|
|
86060456fa | ||
|
|
ca0ac8cf6e | ||
|
|
ba0437ff01 | ||
|
|
99a9ee3cc1 | ||
|
|
f2ca0cecf6 | ||
|
|
0ff96f4857 | ||
|
|
e2cf5d2e37 | ||
|
|
d6d52e80cf | ||
|
|
30691e2e0d | ||
|
|
7308ab6721 | ||
|
|
fd184a4700 | ||
|
|
af19f6c75a | ||
|
|
10ead48014 | ||
|
|
463747b82f | ||
|
|
d99b53f84c | ||
|
|
bbbe85e0fd | ||
|
|
df0ef06495 | ||
|
|
130a6934d6 | ||
|
|
3ea8c0d5a0 | ||
|
|
fd5ba1c538 | ||
|
|
91ac0b3eb0 | ||
|
|
c26ac378f6 | ||
|
|
bf66e94e6d | ||
|
|
552a90685a | ||
|
|
e2246cf8e8 | ||
|
|
455696275b | ||
|
|
0ffa945668 | ||
|
|
6c40e2311e | ||
|
|
fbac1fae0b | ||
|
|
127d1f4127 | ||
|
|
7ce4efff70 | ||
|
|
dd9ec0aacc | ||
|
|
6d081c7489 | ||
|
|
500d880aea | ||
|
|
4995b6023b | ||
|
|
ea8c21ff21 | ||
|
|
a9b1cd6549 | ||
|
|
e3745d5071 | ||
|
|
82aa3cb425 | ||
|
|
b57ebc7500 | ||
|
|
740b691f67 | ||
|
|
5e509b1b57 | ||
|
|
111dbc43f2 | ||
|
|
d704b04e3d | ||
|
|
0d5f9a7cec | ||
|
|
f66d2855a7 | ||
|
|
fe588ea05a | ||
|
|
704a8dbf95 | ||
|
|
4f64880cf8 | ||
|
|
63733651d5 | ||
|
|
88e0b3df63 | ||
|
|
9b98a9e509 | ||
|
|
52565cc55c | ||
|
|
023df71d3c | ||
|
|
bce6b3be94 | ||
|
|
3227fb6696 | ||
|
|
e5b63dbb6a | ||
|
|
183dbeb29a | ||
|
|
da79575db6 | ||
|
|
b08b732b61 | ||
|
|
3f838ce253 | ||
|
|
938d3d6f17 | ||
|
|
321842a021 | ||
|
|
db640de638 | ||
|
|
3c511a0f53 | ||
|
|
cf80be9611 | ||
|
|
38dd56df26 | ||
|
|
97dd062477 | ||
|
|
771282187d | ||
|
|
445de6b8fc | ||
|
|
d1b311a7c5 | ||
|
|
9f5b858ffb | ||
|
|
1e5c4de4ed | ||
|
|
1f0db4c4d2 | ||
|
|
7a24c0cd2b | ||
|
|
4c164e6bad | ||
|
|
7fc521023f | ||
|
|
99efd20e7a | ||
|
|
2e8403a056 | ||
|
|
8914cc2b0b | ||
|
|
364b876c8c | ||
|
|
453087b3ce | ||
|
|
d023fc385f | ||
|
|
8e81c6d7b0 | ||
|
|
b1b87e1acc | ||
|
|
68593dff9e | ||
|
|
49d65b87cd | ||
|
|
0f6c9eff32 | ||
|
|
e29d145397 | ||
|
|
0fa930a0ff | ||
|
|
400bda9e72 | ||
|
|
dd14c6ba4e | ||
|
|
f9005e6063 | ||
|
|
58b8eef92f | ||
|
|
08afce15b0 | ||
|
|
6ac8361aa6 | ||
|
|
014590fb9e | ||
|
|
217c068bfb | ||
|
|
fb06cfd33f | ||
|
|
aac621bd16 | ||
|
|
6252e4f04e |
@@ -15,6 +15,14 @@ on:
|
||||
required: true
|
||||
default: 'main'
|
||||
type: string
|
||||
android_variant:
|
||||
description: 'Android build variant'
|
||||
required: true
|
||||
default: 'dev'
|
||||
type: choice
|
||||
options:
|
||||
- dev
|
||||
- prod
|
||||
|
||||
jobs:
|
||||
build-and-test-web-and-api:
|
||||
@@ -67,6 +75,9 @@ jobs:
|
||||
build-and-test-android:
|
||||
name: Build and Test Android
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
variant: [dev, prod]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -89,19 +100,40 @@ jobs:
|
||||
- name: Grant execute permission for gradlew
|
||||
run: chmod +x android/gradlew
|
||||
|
||||
- name: Create debug keystore
|
||||
run: |
|
||||
mkdir -p ~/.android
|
||||
keytool -genkey -v -keystore ~/.android/debug.keystore -storepass android -alias androiddebugkey -keypass android -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=Android Debug,O=Android,C=US"
|
||||
|
||||
- name: Create google-services.json for dev
|
||||
if: matrix.variant == 'dev'
|
||||
run: |
|
||||
mkdir -p android/app/src/dev
|
||||
echo '${{ secrets.GOOGLE_SERVICES_JSON_DEV }}' > android/app/src/dev/google-services.json
|
||||
|
||||
- name: Create google-services.json for prod
|
||||
if: matrix.variant == 'prod'
|
||||
run: |
|
||||
mkdir -p android/app/src/prod
|
||||
echo '${{ secrets.GOOGLE_SERVICES_JSON_PROD }}' > android/app/src/prod/google-services.json
|
||||
|
||||
- name: Build Android app
|
||||
run: |
|
||||
cd android
|
||||
./gradlew assembleDebug
|
||||
./gradlew assemble${{ matrix.variant }}Debug
|
||||
|
||||
# - name: Run Android tests
|
||||
# run: |
|
||||
# cd android
|
||||
# ./gradlew testDebugUnitTest
|
||||
# ./gradlew test${{ matrix.variant }}DebugUnitTest
|
||||
|
||||
- name: Sanitize ref name for artifact
|
||||
shell: bash
|
||||
run: echo "SAFE_REF_NAME=${GITHUB_REF_NAME//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: textbee.dev-build-${{ github.ref_name }}-${{ github.sha }}.apk
|
||||
path: android/app/build/outputs/apk/debug/app-debug.apk
|
||||
name: textbee-${{ matrix.variant }}-build-${{ env.SAFE_REF_NAME }}-${{ github.sha }}.apk
|
||||
path: android/app/build/outputs/apk/${{ matrix.variant }}/debug/app-${{ matrix.variant }}-debug.apk
|
||||
retention-days: 7
|
||||
@@ -1,3 +1,5 @@
|
||||
.vercel
|
||||
.DS_Store
|
||||
*-monitor
|
||||
|
||||
.cursor
|
||||
+8
-1
@@ -13,4 +13,11 @@
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
/app/release/*
|
||||
/app/release/*
|
||||
|
||||
*.apk
|
||||
|
||||
# Firebase configuration files
|
||||
/app/src/dev/google-services.json
|
||||
/app/src/prod/google-services.json
|
||||
/app/google-services.json
|
||||
Generated
+1
-1
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<bytecodeTargetLevel target="11" />
|
||||
<bytecodeTargetLevel target="17" />
|
||||
</component>
|
||||
</project>
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="deploymentTargetDropDown">
|
||||
<value>
|
||||
<entry key="app">
|
||||
<State>
|
||||
<runningDeviceTargetSelectedWithDropDown>
|
||||
<Target>
|
||||
<type value="RUNNING_DEVICE_TARGET" />
|
||||
<deviceKey>
|
||||
<Key>
|
||||
<type value="SERIAL_NUMBER" />
|
||||
<value value="adb-RFCX417HC5V-ZWjtU2._adb-tls-connect._tcp." />
|
||||
</Key>
|
||||
</deviceKey>
|
||||
</Target>
|
||||
</runningDeviceTargetSelectedWithDropDown>
|
||||
<timeTargetWasSelectedWithDropDown value="2025-08-09T15:50:54.600783Z" />
|
||||
</State>
|
||||
</entry>
|
||||
</value>
|
||||
</component>
|
||||
</project>
|
||||
Generated
+2
-2
@@ -4,15 +4,15 @@
|
||||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<GradleProjectSettings>
|
||||
<option name="testRunner" value="GRADLE" />
|
||||
<option name="distributionType" value="DEFAULT_WRAPPED" />
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="gradleJvm" value="jbr-17" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
<option value="$PROJECT_DIR$/app" />
|
||||
</set>
|
||||
</option>
|
||||
<option name="resolveExternalAnnotations" value="false" />
|
||||
</GradleProjectSettings>
|
||||
</option>
|
||||
</component>
|
||||
|
||||
Generated
+10
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectMigrations">
|
||||
<option name="MigrateToGradleLocalJavaHome">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
</set>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
Generated
+1
-2
@@ -1,4 +1,3 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DesignSurface">
|
||||
<option name="filePathToZoomLevelMap">
|
||||
@@ -7,7 +6,7 @@
|
||||
</map>
|
||||
</option>
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" default="true" project-jdk-name="Android Studio default JDK" project-jdk-type="JavaSDK">
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="jbr-17" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||
</component>
|
||||
<component name="ProjectType">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'com.google.gms.google-services'
|
||||
id 'com.google.firebase.crashlytics'
|
||||
}
|
||||
|
||||
@@ -8,11 +7,10 @@ android {
|
||||
compileSdk 32
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.vernu.sms"
|
||||
minSdk 24
|
||||
targetSdk 32
|
||||
versionCode 15
|
||||
versionName "2.6.2"
|
||||
versionCode 16
|
||||
versionName "2.7.0"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
@@ -22,13 +20,50 @@ android {
|
||||
// }
|
||||
// }
|
||||
}
|
||||
signingConfigs {
|
||||
debug {
|
||||
storeFile file("${System.getenv("HOME")}/.android/debug.keystore")
|
||||
storePassword "android"
|
||||
keyAlias "androiddebugkey"
|
||||
keyPassword "android"
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
// TODO: Change to release signing config
|
||||
signingConfig signingConfigs.debug
|
||||
}
|
||||
debug {
|
||||
minifyEnabled false
|
||||
debuggable true
|
||||
signingConfig signingConfigs.debug
|
||||
}
|
||||
}
|
||||
|
||||
flavorDimensions "environment"
|
||||
|
||||
productFlavors {
|
||||
dev {
|
||||
dimension "environment"
|
||||
applicationId "com.vernu.sms.dev"
|
||||
versionNameSuffix "-dev"
|
||||
buildConfigField "String", "API_BASE_URL", '"https://api.dev.textbee.dev/api/v1/"'
|
||||
buildConfigField "String", "ENVIRONMENT", '"development"'
|
||||
resValue "string", "app_name", "textbee.dev (Dev)"
|
||||
}
|
||||
|
||||
prod {
|
||||
dimension "environment"
|
||||
applicationId "com.vernu.sms"
|
||||
buildConfigField "String", "API_BASE_URL", '"https://api.textbee.dev/api/v1/"'
|
||||
buildConfigField "String", "ENVIRONMENT", '"production"'
|
||||
resValue "string", "app_name", "textbee.dev"
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
@@ -62,3 +97,4 @@ dependencies {
|
||||
// annotationProcessor "androidx.room:room-compiler:$room_version"
|
||||
}
|
||||
|
||||
apply plugin: 'com.google.gms.google-services'
|
||||
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "14326313838",
|
||||
"project_id": "vernu-sms",
|
||||
"storage_bucket": "vernu-sms.appspot.com"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:14326313838:android:4ea4380d88b057e9eb877f",
|
||||
"android_client_info": {
|
||||
"package_name": "com.vernu.sms"
|
||||
}
|
||||
},
|
||||
"oauth_client": [
|
||||
{
|
||||
"client_id": "14326313838-0kmpefogqk8h5ntf2mth4e9cht9pbfai.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
}
|
||||
],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyDNZP2HeVPZ6_V9oq2UTYWmJ4FfenrRBIQ"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": [
|
||||
{
|
||||
"client_id": "14326313838-0kmpefogqk8h5ntf2mth4e9cht9pbfai.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "YOUR_DEV_PROJECT_NUMBER",
|
||||
"project_id": "your-dev-project-id",
|
||||
"storage_bucket": "your-dev-project-id.appspot.com"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:YOUR_DEV_PROJECT_NUMBER:android:YOUR_DEV_APP_ID",
|
||||
"android_client_info": {
|
||||
"package_name": "com.vernu.sms.dev"
|
||||
}
|
||||
},
|
||||
"oauth_client": [
|
||||
{
|
||||
"client_id": "YOUR_DEV_CLIENT_ID",
|
||||
"client_type": 3
|
||||
}
|
||||
],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "YOUR_DEV_API_KEY"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": [
|
||||
{
|
||||
"client_id": "YOUR_DEV_CLIENT_ID",
|
||||
"client_type": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<application
|
||||
android:name=".SMSGatewayApplication"
|
||||
android:allowBackup="true"
|
||||
@@ -80,6 +81,11 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".activities.SMSFilterActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.SMSGateway.NoActionBar"
|
||||
android:parentActivityName=".activities.MainActivity" />
|
||||
<activity
|
||||
android:name="com.journeyapps.barcodescanner.CaptureActivity"
|
||||
android:screenOrientation="portrait"
|
||||
|
||||
@@ -3,7 +3,7 @@ package com.vernu.sms;
|
||||
import android.Manifest;
|
||||
|
||||
public class AppConstants {
|
||||
public static final String API_BASE_URL = "https://api.textbee.dev/api/v1/";
|
||||
public static final String API_BASE_URL = BuildConfig.API_BASE_URL;
|
||||
public static final String[] requiredPermissions = new String[]{
|
||||
Manifest.permission.SEND_SMS,
|
||||
Manifest.permission.READ_SMS,
|
||||
@@ -19,4 +19,9 @@ public class AppConstants {
|
||||
public static final String SHARED_PREFS_LAST_VERSION_CODE_KEY = "LAST_VERSION_CODE";
|
||||
public static final String SHARED_PREFS_LAST_VERSION_NAME_KEY = "LAST_VERSION_NAME";
|
||||
public static final String SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY = "STICKY_NOTIFICATION_ENABLED";
|
||||
public static final String HEARTBEAT_WORK_TAG = "heartbeat";
|
||||
public static final String SHARED_PREFS_HEARTBEAT_ENABLED_KEY = "HEARTBEAT_ENABLED";
|
||||
public static final String SHARED_PREFS_HEARTBEAT_INTERVAL_MINUTES_KEY = "HEARTBEAT_INTERVAL_MINUTES";
|
||||
public static final String SHARED_PREFS_SMS_FILTER_CONFIG_KEY = "SMS_FILTER_CONFIG";
|
||||
public static final String SHARED_PREFS_DEVICE_NAME_KEY = "DEVICE_NAME";
|
||||
}
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
package com.vernu.sms;
|
||||
|
||||
import android.app.Application;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.work.Configuration;
|
||||
import androidx.work.WorkManager;
|
||||
|
||||
public class SMSGatewayApplication extends Application implements Configuration.Provider {
|
||||
private static final String TAG = "SMSGatewayApplication";
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
|
||||
// Initialize WorkManager early to ensure it's ready for background work
|
||||
// This is important for background tasks like heartbeat
|
||||
try {
|
||||
WorkManager.initialize(this, getWorkManagerConfiguration());
|
||||
Log.d(TAG, "WorkManager initialized successfully");
|
||||
} catch (IllegalStateException e) {
|
||||
// WorkManager might already be initialized (e.g., by androidx.startup)
|
||||
// This is fine, we can continue
|
||||
Log.d(TAG, "WorkManager already initialized or will be initialized automatically");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -15,6 +15,7 @@ import androidx.core.content.ContextCompat;
|
||||
import com.google.firebase.crashlytics.FirebaseCrashlytics;
|
||||
import com.vernu.sms.services.StickyNotificationService;
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper;
|
||||
import com.vernu.sms.dtos.SimInfoDTO;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
@@ -117,4 +118,172 @@ public class TextBeeUtils {
|
||||
public static void logException(Throwable throwable, String message) {
|
||||
logException(throwable, message, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects all available SIM information (physical SIMs and eSIMs) from the device
|
||||
*
|
||||
* @param context The application context
|
||||
* @return List of SimInfoDTO objects containing SIM information, or empty list if permission not granted
|
||||
*/
|
||||
public static List<SimInfoDTO> collectSimInfo(Context context) {
|
||||
List<SimInfoDTO> simInfoList = new ArrayList<>();
|
||||
|
||||
if (ActivityCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) != PackageManager.PERMISSION_GRANTED) {
|
||||
Log.w(TAG, "READ_PHONE_STATE permission not granted, cannot collect SIM info");
|
||||
return simInfoList;
|
||||
}
|
||||
|
||||
try {
|
||||
SubscriptionManager subscriptionManager = SubscriptionManager.from(context);
|
||||
List<SubscriptionInfo> subscriptionInfoList = subscriptionManager.getActiveSubscriptionInfoList();
|
||||
|
||||
if (subscriptionInfoList == null) {
|
||||
Log.d(TAG, "No active subscriptions found");
|
||||
return simInfoList;
|
||||
}
|
||||
|
||||
for (SubscriptionInfo subscriptionInfo : subscriptionInfoList) {
|
||||
SimInfoDTO simInfo = new SimInfoDTO();
|
||||
simInfo.setSubscriptionId(subscriptionInfo.getSubscriptionId());
|
||||
|
||||
// Get ICCID (may be null for eSIM)
|
||||
try {
|
||||
String iccId = subscriptionInfo.getIccId();
|
||||
if (iccId != null && !iccId.isEmpty()) {
|
||||
simInfo.setIccId(iccId);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.d(TAG, "Could not get ICCID for subscription " + subscriptionInfo.getSubscriptionId());
|
||||
}
|
||||
|
||||
// Get Card ID
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
int cardId = subscriptionInfo.getCardId();
|
||||
// INVALID_CARD_ID is -1, check for valid card ID (>= 0)
|
||||
if (cardId >= 0) {
|
||||
simInfo.setCardId(cardId);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.d(TAG, "Could not get Card ID for subscription " + subscriptionInfo.getSubscriptionId());
|
||||
}
|
||||
|
||||
// Get carrier name
|
||||
try {
|
||||
CharSequence carrierName = subscriptionInfo.getCarrierName();
|
||||
if (carrierName != null) {
|
||||
simInfo.setCarrierName(carrierName.toString());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.d(TAG, "Could not get carrier name for subscription " + subscriptionInfo.getSubscriptionId());
|
||||
}
|
||||
|
||||
// Get display name
|
||||
try {
|
||||
CharSequence displayName = subscriptionInfo.getDisplayName();
|
||||
if (displayName != null) {
|
||||
simInfo.setDisplayName(displayName.toString());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.d(TAG, "Could not get display name for subscription " + subscriptionInfo.getSubscriptionId());
|
||||
}
|
||||
|
||||
// Get SIM slot index
|
||||
try {
|
||||
int simSlotIndex = subscriptionInfo.getSimSlotIndex();
|
||||
if (simSlotIndex >= 0) {
|
||||
simInfo.setSimSlotIndex(simSlotIndex);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.d(TAG, "Could not get SIM slot index for subscription " + subscriptionInfo.getSubscriptionId());
|
||||
}
|
||||
|
||||
// Get MCC
|
||||
try {
|
||||
String mcc = subscriptionInfo.getMccString();
|
||||
if (mcc != null && !mcc.isEmpty()) {
|
||||
simInfo.setMcc(mcc);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.d(TAG, "Could not get MCC for subscription " + subscriptionInfo.getSubscriptionId());
|
||||
}
|
||||
|
||||
// Get MNC
|
||||
try {
|
||||
String mnc = subscriptionInfo.getMncString();
|
||||
if (mnc != null && !mnc.isEmpty()) {
|
||||
simInfo.setMnc(mnc);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.d(TAG, "Could not get MNC for subscription " + subscriptionInfo.getSubscriptionId());
|
||||
}
|
||||
|
||||
// Get country ISO
|
||||
try {
|
||||
String countryIso = subscriptionInfo.getCountryIso();
|
||||
if (countryIso != null && !countryIso.isEmpty()) {
|
||||
simInfo.setCountryIso(countryIso);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.d(TAG, "Could not get country ISO for subscription " + subscriptionInfo.getSubscriptionId());
|
||||
}
|
||||
|
||||
// Get subscription type (0 = physical SIM, 1 = eSIM)
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
int subscriptionType = subscriptionInfo.getSubscriptionType();
|
||||
if (subscriptionType == SubscriptionManager.SUBSCRIPTION_TYPE_LOCAL_SIM) {
|
||||
simInfo.setSubscriptionType("PHYSICAL_SIM");
|
||||
} else if (subscriptionType == SubscriptionManager.SUBSCRIPTION_TYPE_REMOTE_SIM) {
|
||||
simInfo.setSubscriptionType("ESIM");
|
||||
}
|
||||
} else {
|
||||
// For older Android versions, default to PHYSICAL_SIM
|
||||
simInfo.setSubscriptionType("PHYSICAL_SIM");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.d(TAG, "Could not get subscription type for subscription " + subscriptionInfo.getSubscriptionId());
|
||||
}
|
||||
|
||||
simInfoList.add(simInfo);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error collecting SIM info: " + e.getMessage(), e);
|
||||
}
|
||||
|
||||
return simInfoList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if a subscription ID exists in the active subscriptions
|
||||
*
|
||||
* @param context The application context
|
||||
* @param subscriptionId The subscription ID to validate
|
||||
* @return true if the subscription ID exists, false otherwise
|
||||
*/
|
||||
public static boolean isValidSubscriptionId(Context context, int subscriptionId) {
|
||||
if (ActivityCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) != PackageManager.PERMISSION_GRANTED) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
SubscriptionManager subscriptionManager = SubscriptionManager.from(context);
|
||||
List<SubscriptionInfo> subscriptionInfoList = subscriptionManager.getActiveSubscriptionInfoList();
|
||||
|
||||
if (subscriptionInfoList == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (SubscriptionInfo subscriptionInfo : subscriptionInfoList) {
|
||||
if (subscriptionInfo.getSubscriptionId() == subscriptionId) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error validating subscription ID: " + e.getMessage(), e);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.content.ClipData;
|
||||
import android.content.ClipboardManager;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import com.vernu.sms.activities.SMSFilterActivity;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
@@ -30,9 +31,14 @@ import com.vernu.sms.TextBeeUtils;
|
||||
import com.vernu.sms.R;
|
||||
import com.vernu.sms.dtos.RegisterDeviceInputDTO;
|
||||
import com.vernu.sms.dtos.RegisterDeviceResponseDTO;
|
||||
import com.vernu.sms.dtos.SimInfoCollectionDTO;
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper;
|
||||
import com.vernu.sms.helpers.VersionTracker;
|
||||
import com.vernu.sms.helpers.HeartbeatManager;
|
||||
import com.google.firebase.crashlytics.FirebaseCrashlytics;
|
||||
import com.google.gson.Gson;
|
||||
import okhttp3.ResponseBody;
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
import retrofit2.Call;
|
||||
@@ -43,8 +49,8 @@ public class MainActivity extends AppCompatActivity {
|
||||
|
||||
private Context mContext;
|
||||
private Switch gatewaySwitch, receiveSMSSwitch, stickyNotificationSwitch;
|
||||
private EditText apiKeyEditText, fcmTokenEditText, deviceIdEditText;
|
||||
private Button registerDeviceBtn, grantSMSPermissionBtn, scanQRBtn, checkUpdatesBtn;
|
||||
private EditText apiKeyEditText, fcmTokenEditText, deviceIdEditText, deviceNameEditText;
|
||||
private Button registerDeviceBtn, grantSMSPermissionBtn, scanQRBtn, checkUpdatesBtn, configureFilterBtn;
|
||||
private ImageButton copyDeviceIdImgBtn;
|
||||
private TextView deviceBrandAndModelTxt, deviceIdTxt, appVersionNameTxt, appVersionCodeTxt;
|
||||
private RadioGroup defaultSimSlotRadioGroup;
|
||||
@@ -66,6 +72,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
apiKeyEditText = findViewById(R.id.apiKeyEditText);
|
||||
fcmTokenEditText = findViewById(R.id.fcmTokenEditText);
|
||||
deviceIdEditText = findViewById(R.id.deviceIdEditText);
|
||||
deviceNameEditText = findViewById(R.id.deviceNameEditText);
|
||||
registerDeviceBtn = findViewById(R.id.registerDeviceBtn);
|
||||
grantSMSPermissionBtn = findViewById(R.id.grantSMSPermissionBtn);
|
||||
scanQRBtn = findViewById(R.id.scanQRButton);
|
||||
@@ -76,6 +83,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
appVersionNameTxt = findViewById(R.id.appVersionNameTxt);
|
||||
appVersionCodeTxt = findViewById(R.id.appVersionCodeTxt);
|
||||
checkUpdatesBtn = findViewById(R.id.checkUpdatesBtn);
|
||||
configureFilterBtn = findViewById(R.id.configureFilterBtn);
|
||||
|
||||
deviceIdTxt.setText(deviceId);
|
||||
deviceIdEditText.setText(deviceId);
|
||||
@@ -107,6 +115,12 @@ public class MainActivity extends AppCompatActivity {
|
||||
Log.d(TAG, "Starting sticky notification service on app start");
|
||||
}
|
||||
|
||||
// Schedule heartbeat if device is enabled and registered
|
||||
if (gatewayEnabled && !deviceId.isEmpty()) {
|
||||
HeartbeatManager.scheduleHeartbeat(mContext);
|
||||
Log.d(TAG, "Scheduling heartbeat on app start");
|
||||
}
|
||||
|
||||
if (deviceId == null || deviceId.isEmpty()) {
|
||||
registerDeviceBtn.setText("Register");
|
||||
} else {
|
||||
@@ -134,6 +148,12 @@ public class MainActivity extends AppCompatActivity {
|
||||
});
|
||||
|
||||
apiKeyEditText.setText(SharedPreferenceHelper.getSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_API_KEY_KEY, ""));
|
||||
String storedDeviceName = SharedPreferenceHelper.getSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_DEVICE_NAME_KEY, "");
|
||||
if (storedDeviceName.isEmpty()) {
|
||||
deviceNameEditText.setText(Build.BRAND + " " + Build.MODEL);
|
||||
} else {
|
||||
deviceNameEditText.setText(storedDeviceName);
|
||||
}
|
||||
gatewaySwitch.setChecked(SharedPreferenceHelper.getSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, false));
|
||||
gatewaySwitch.setOnCheckedChangeListener((compoundButton, isCheked) -> {
|
||||
View view = compoundButton.getRootView();
|
||||
@@ -151,7 +171,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
public void onResponse(Call<RegisterDeviceResponseDTO> call, Response<RegisterDeviceResponseDTO> response) {
|
||||
Log.d(TAG, response.toString());
|
||||
if (!response.isSuccessful()) {
|
||||
Snackbar.make(view, response.message().isEmpty() ? "An error occurred :( "+ response.code() : response.message(), Snackbar.LENGTH_LONG).show();
|
||||
Snackbar.make(view, extractErrorMessage(response), Snackbar.LENGTH_LONG).show();
|
||||
compoundButton.setEnabled(true);
|
||||
return;
|
||||
}
|
||||
@@ -164,8 +184,12 @@ public class MainActivity extends AppCompatActivity {
|
||||
if (SharedPreferenceHelper.getSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY, false)) {
|
||||
TextBeeUtils.startStickyNotificationService(mContext);
|
||||
}
|
||||
// Schedule heartbeat
|
||||
HeartbeatManager.scheduleHeartbeat(mContext);
|
||||
} else {
|
||||
TextBeeUtils.stopStickyNotificationService(mContext);
|
||||
// Cancel heartbeat
|
||||
HeartbeatManager.cancelHeartbeat(mContext);
|
||||
}
|
||||
compoundButton.setEnabled(true);
|
||||
}
|
||||
@@ -226,6 +250,11 @@ public class MainActivity extends AppCompatActivity {
|
||||
Intent browserIntent = new Intent(Intent.ACTION_VIEW, android.net.Uri.parse(downloadUrl));
|
||||
startActivity(browserIntent);
|
||||
});
|
||||
|
||||
configureFilterBtn.setOnClickListener(view -> {
|
||||
Intent filterIntent = new Intent(MainActivity.this, SMSFilterActivity.class);
|
||||
startActivity(filterIntent);
|
||||
});
|
||||
}
|
||||
|
||||
private void renderAvailableSimOptions() {
|
||||
@@ -245,7 +274,8 @@ public class MainActivity extends AppCompatActivity {
|
||||
|
||||
// Create radio buttons for each SIM with proper styling
|
||||
TextBeeUtils.getAvailableSimSlots(mContext).forEach(subscriptionInfo -> {
|
||||
String simInfo = "SIM " + (subscriptionInfo.getSimSlotIndex() + 1) + " (" + subscriptionInfo.getDisplayName() + ")";
|
||||
String displayName = subscriptionInfo.getDisplayName() != null ? subscriptionInfo.getDisplayName().toString() : "Unknown";
|
||||
String simInfo = displayName + " (Subscription ID: " + subscriptionInfo.getSubscriptionId() + ")";
|
||||
RadioButton radioButton = new RadioButton(mContext);
|
||||
radioButton.setText(simInfo);
|
||||
radioButton.setId(subscriptionInfo.getSubscriptionId());
|
||||
@@ -280,6 +310,44 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts error message from API response, trying multiple sources:
|
||||
* 1. Error message from response body (error field)
|
||||
* 2. Response message from HTTP headers
|
||||
* 3. Generic error with status code as fallback
|
||||
*/
|
||||
private String extractErrorMessage(Response<?> response) {
|
||||
// Try to parse error from response body
|
||||
try {
|
||||
ResponseBody errorBody = response.errorBody();
|
||||
if (errorBody != null) {
|
||||
String errorBodyString = errorBody.string();
|
||||
if (errorBodyString != null && !errorBodyString.isEmpty()) {
|
||||
try {
|
||||
Gson gson = new Gson();
|
||||
RegisterDeviceResponseDTO errorResponse = gson.fromJson(errorBodyString, RegisterDeviceResponseDTO.class);
|
||||
if (errorResponse != null && errorResponse.error != null && !errorResponse.error.isEmpty()) {
|
||||
return errorResponse.error;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// If JSON parsing fails, try to extract message from raw string
|
||||
Log.d(TAG, "Could not parse error response as JSON: " + errorBodyString);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Log.d(TAG, "Could not read error body: " + e.getMessage());
|
||||
}
|
||||
|
||||
// Fall back to response message
|
||||
if (response.message() != null && !response.message().isEmpty()) {
|
||||
return response.message();
|
||||
}
|
||||
|
||||
// Final fallback to generic error with status code
|
||||
return "An error occurred :( " + response.code();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the custom radio button style to a programmatically created radio button
|
||||
*/
|
||||
@@ -364,6 +432,19 @@ public class MainActivity extends AppCompatActivity {
|
||||
registerDeviceInput.setAppVersionCode(BuildConfig.VERSION_CODE);
|
||||
registerDeviceInput.setAppVersionName(BuildConfig.VERSION_NAME);
|
||||
|
||||
// Get device name from input field or default to "brand model"
|
||||
String deviceName = deviceNameEditText.getText().toString().trim();
|
||||
if (deviceName.isEmpty()) {
|
||||
deviceName = Build.BRAND + " " + Build.MODEL;
|
||||
}
|
||||
registerDeviceInput.setName(deviceName);
|
||||
|
||||
// Collect SIM information
|
||||
SimInfoCollectionDTO simInfoCollection = new SimInfoCollectionDTO();
|
||||
simInfoCollection.setLastUpdated(System.currentTimeMillis());
|
||||
simInfoCollection.setSims(TextBeeUtils.collectSimInfo(mContext));
|
||||
registerDeviceInput.setSimInfo(simInfoCollection);
|
||||
|
||||
// If the user provided a device ID, use it for updating instead of creating new
|
||||
if (!deviceIdInput.isEmpty()) {
|
||||
Log.d(TAG, "Updating device with deviceId: "+ deviceIdInput);
|
||||
@@ -373,7 +454,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
public void onResponse(Call<RegisterDeviceResponseDTO> call, Response<RegisterDeviceResponseDTO> response) {
|
||||
Log.d(TAG, response.toString());
|
||||
if (!response.isSuccessful()) {
|
||||
Snackbar.make(view, response.message().isEmpty() ? "An error occurred :( "+ response.code() : response.message(), Snackbar.LENGTH_LONG).show();
|
||||
Snackbar.make(view, extractErrorMessage(response), Snackbar.LENGTH_LONG).show();
|
||||
registerDeviceBtn.setEnabled(true);
|
||||
registerDeviceBtn.setText("Update");
|
||||
return;
|
||||
@@ -389,6 +470,29 @@ public class MainActivity extends AppCompatActivity {
|
||||
SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, deviceId);
|
||||
SharedPreferenceHelper.setSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, registerDeviceInput.isEnabled());
|
||||
gatewaySwitch.setChecked(registerDeviceInput.isEnabled());
|
||||
|
||||
// Sync heartbeatIntervalMinutes from server response
|
||||
if (response.body().data.get("heartbeatIntervalMinutes") != null) {
|
||||
Object intervalObj = response.body().data.get("heartbeatIntervalMinutes");
|
||||
if (intervalObj instanceof Number) {
|
||||
int intervalMinutes = ((Number) intervalObj).intValue();
|
||||
SharedPreferenceHelper.setSharedPreferenceInt(mContext, AppConstants.SHARED_PREFS_HEARTBEAT_INTERVAL_MINUTES_KEY, intervalMinutes);
|
||||
Log.d(TAG, "Synced heartbeat interval from server: " + intervalMinutes + " minutes");
|
||||
}
|
||||
}
|
||||
|
||||
// Sync device name from server response
|
||||
if (response.body().data.get("name") != null) {
|
||||
String deviceName = response.body().data.get("name").toString();
|
||||
SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_DEVICE_NAME_KEY, deviceName);
|
||||
deviceNameEditText.setText(deviceName);
|
||||
Log.d(TAG, "Synced device name from server: " + deviceName);
|
||||
}
|
||||
|
||||
// Schedule heartbeat if device is enabled
|
||||
if (registerDeviceInput.isEnabled()) {
|
||||
HeartbeatManager.scheduleHeartbeat(mContext);
|
||||
}
|
||||
}
|
||||
|
||||
// Update stored version information
|
||||
@@ -412,18 +516,18 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
|
||||
Call<RegisterDeviceResponseDTO> apiCall = ApiManager.getApiService().registerDevice(newKey, registerDeviceInput);
|
||||
apiCall.enqueue(new Callback<RegisterDeviceResponseDTO>() {
|
||||
@Override
|
||||
public void onResponse(Call<RegisterDeviceResponseDTO> call, Response<RegisterDeviceResponseDTO> response) {
|
||||
Log.d(TAG, response.toString());
|
||||
if (!response.isSuccessful()) {
|
||||
Snackbar.make(view, response.message().isEmpty() ? "An error occurred :( "+ response.code() : response.message(), Snackbar.LENGTH_LONG).show();
|
||||
registerDeviceBtn.setEnabled(true);
|
||||
registerDeviceBtn.setText("Update");
|
||||
return;
|
||||
}
|
||||
SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_API_KEY_KEY, newKey);
|
||||
Snackbar.make(view, "Device Registration Successful :)", Snackbar.LENGTH_LONG).show();
|
||||
apiCall.enqueue(new Callback<RegisterDeviceResponseDTO>() {
|
||||
@Override
|
||||
public void onResponse(Call<RegisterDeviceResponseDTO> call, Response<RegisterDeviceResponseDTO> response) {
|
||||
Log.d(TAG, response.toString());
|
||||
if (!response.isSuccessful()) {
|
||||
Snackbar.make(view, extractErrorMessage(response), Snackbar.LENGTH_LONG).show();
|
||||
registerDeviceBtn.setEnabled(true);
|
||||
registerDeviceBtn.setText("Update");
|
||||
return;
|
||||
}
|
||||
SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_API_KEY_KEY, newKey);
|
||||
Snackbar.make(view, "Device Registration Successful :)", Snackbar.LENGTH_LONG).show();
|
||||
|
||||
if (response.body() != null && response.body().data != null && response.body().data.get("_id") != null) {
|
||||
deviceId = response.body().data.get("_id").toString();
|
||||
@@ -432,6 +536,29 @@ public class MainActivity extends AppCompatActivity {
|
||||
SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, deviceId);
|
||||
SharedPreferenceHelper.setSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, registerDeviceInput.isEnabled());
|
||||
gatewaySwitch.setChecked(registerDeviceInput.isEnabled());
|
||||
|
||||
// Sync heartbeatIntervalMinutes from server response
|
||||
if (response.body().data.get("heartbeatIntervalMinutes") != null) {
|
||||
Object intervalObj = response.body().data.get("heartbeatIntervalMinutes");
|
||||
if (intervalObj instanceof Number) {
|
||||
int intervalMinutes = ((Number) intervalObj).intValue();
|
||||
SharedPreferenceHelper.setSharedPreferenceInt(mContext, AppConstants.SHARED_PREFS_HEARTBEAT_INTERVAL_MINUTES_KEY, intervalMinutes);
|
||||
Log.d(TAG, "Synced heartbeat interval from server: " + intervalMinutes + " minutes");
|
||||
}
|
||||
}
|
||||
|
||||
// Sync device name from server response
|
||||
if (response.body().data.get("name") != null) {
|
||||
String deviceName = response.body().data.get("name").toString();
|
||||
SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_DEVICE_NAME_KEY, deviceName);
|
||||
deviceNameEditText.setText(deviceName);
|
||||
Log.d(TAG, "Synced device name from server: " + deviceName);
|
||||
}
|
||||
|
||||
// Schedule heartbeat if device is enabled
|
||||
if (registerDeviceInput.isEnabled()) {
|
||||
HeartbeatManager.scheduleHeartbeat(mContext);
|
||||
}
|
||||
}
|
||||
|
||||
// Update stored version information
|
||||
@@ -484,13 +611,26 @@ public class MainActivity extends AppCompatActivity {
|
||||
updateDeviceInput.setAppVersionCode(BuildConfig.VERSION_CODE);
|
||||
updateDeviceInput.setAppVersionName(BuildConfig.VERSION_NAME);
|
||||
|
||||
// Get device name from input field or default to "brand model"
|
||||
String deviceName = deviceNameEditText.getText().toString().trim();
|
||||
if (deviceName.isEmpty()) {
|
||||
deviceName = Build.BRAND + " " + Build.MODEL;
|
||||
}
|
||||
updateDeviceInput.setName(deviceName);
|
||||
|
||||
// Collect SIM information
|
||||
SimInfoCollectionDTO simInfoCollection = new SimInfoCollectionDTO();
|
||||
simInfoCollection.setLastUpdated(System.currentTimeMillis());
|
||||
simInfoCollection.setSims(TextBeeUtils.collectSimInfo(mContext));
|
||||
updateDeviceInput.setSimInfo(simInfoCollection);
|
||||
|
||||
Call<RegisterDeviceResponseDTO> apiCall = ApiManager.getApiService().updateDevice(deviceIdToUse, apiKey, updateDeviceInput);
|
||||
apiCall.enqueue(new Callback<RegisterDeviceResponseDTO>() {
|
||||
@Override
|
||||
public void onResponse(Call<RegisterDeviceResponseDTO> call, Response<RegisterDeviceResponseDTO> response) {
|
||||
Log.d(TAG, response.toString());
|
||||
if (!response.isSuccessful()) {
|
||||
Snackbar.make(view, response.message().isEmpty() ? "An error occurred :( "+ response.code() : response.message(), Snackbar.LENGTH_LONG).show();
|
||||
Snackbar.make(view, extractErrorMessage(response), Snackbar.LENGTH_LONG).show();
|
||||
registerDeviceBtn.setEnabled(true);
|
||||
registerDeviceBtn.setText("Update");
|
||||
return;
|
||||
@@ -503,6 +643,29 @@ public class MainActivity extends AppCompatActivity {
|
||||
SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, deviceId);
|
||||
deviceIdTxt.setText(deviceId);
|
||||
deviceIdEditText.setText(deviceId);
|
||||
|
||||
// Sync heartbeatIntervalMinutes from server response
|
||||
if (response.body().data.get("heartbeatIntervalMinutes") != null) {
|
||||
Object intervalObj = response.body().data.get("heartbeatIntervalMinutes");
|
||||
if (intervalObj instanceof Number) {
|
||||
int intervalMinutes = ((Number) intervalObj).intValue();
|
||||
SharedPreferenceHelper.setSharedPreferenceInt(mContext, AppConstants.SHARED_PREFS_HEARTBEAT_INTERVAL_MINUTES_KEY, intervalMinutes);
|
||||
Log.d(TAG, "Synced heartbeat interval from server: " + intervalMinutes + " minutes");
|
||||
}
|
||||
}
|
||||
|
||||
// Sync device name from server response
|
||||
if (response.body().data.get("name") != null) {
|
||||
String deviceName = response.body().data.get("name").toString();
|
||||
SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_DEVICE_NAME_KEY, deviceName);
|
||||
deviceNameEditText.setText(deviceName);
|
||||
Log.d(TAG, "Synced device name from server: " + deviceName);
|
||||
}
|
||||
|
||||
// Schedule heartbeat if device is enabled
|
||||
if (updateDeviceInput.isEnabled()) {
|
||||
HeartbeatManager.scheduleHeartbeat(mContext);
|
||||
}
|
||||
}
|
||||
|
||||
// Update stored version information
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
package com.vernu.sms.activities;
|
||||
|
||||
import android.app.AlertDialog;
|
||||
import android.content.Context;
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ArrayAdapter;
|
||||
import android.widget.Button;
|
||||
import android.widget.ImageButton;
|
||||
import android.widget.RadioButton;
|
||||
import android.widget.RadioGroup;
|
||||
import android.widget.Spinner;
|
||||
import android.widget.Switch;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import com.google.android.material.floatingactionbutton.FloatingActionButton;
|
||||
import com.google.android.material.textfield.TextInputEditText;
|
||||
import com.vernu.sms.R;
|
||||
import com.vernu.sms.helpers.SMSFilterHelper;
|
||||
import com.vernu.sms.models.SMSFilterRule;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class SMSFilterActivity extends AppCompatActivity {
|
||||
private Context mContext;
|
||||
private Switch filterEnabledSwitch;
|
||||
private RadioGroup filterModeRadioGroup;
|
||||
private RadioButton allowListRadio;
|
||||
private RadioButton blockListRadio;
|
||||
private RecyclerView filterRulesRecyclerView;
|
||||
private FloatingActionButton addRuleFab;
|
||||
private FilterRulesAdapter adapter;
|
||||
private SMSFilterHelper.FilterConfig filterConfig;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_sms_filter);
|
||||
|
||||
mContext = getApplicationContext();
|
||||
|
||||
// Initialize views
|
||||
ImageButton backButton = findViewById(R.id.backButton);
|
||||
filterEnabledSwitch = findViewById(R.id.filterEnabledSwitch);
|
||||
filterModeRadioGroup = findViewById(R.id.filterModeRadioGroup);
|
||||
allowListRadio = findViewById(R.id.allowListRadio);
|
||||
blockListRadio = findViewById(R.id.blockListRadio);
|
||||
filterRulesRecyclerView = findViewById(R.id.filterRulesRecyclerView);
|
||||
addRuleFab = findViewById(R.id.addRuleFab);
|
||||
|
||||
// Setup back button
|
||||
backButton.setOnClickListener(v -> finish());
|
||||
|
||||
// Load filter config
|
||||
filterConfig = SMSFilterHelper.loadFilterConfig(mContext);
|
||||
|
||||
// Setup RecyclerView
|
||||
adapter = new FilterRulesAdapter(filterConfig.getRules());
|
||||
filterRulesRecyclerView.setLayoutManager(new LinearLayoutManager(this));
|
||||
filterRulesRecyclerView.setAdapter(adapter);
|
||||
|
||||
// Load current settings
|
||||
filterEnabledSwitch.setChecked(filterConfig.isEnabled());
|
||||
// Default to block list if mode is not set
|
||||
if (filterConfig.getMode() == SMSFilterHelper.FilterMode.ALLOW_LIST) {
|
||||
allowListRadio.setChecked(true);
|
||||
} else {
|
||||
blockListRadio.setChecked(true);
|
||||
}
|
||||
|
||||
// Setup listeners
|
||||
filterEnabledSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> {
|
||||
filterConfig.setEnabled(isChecked);
|
||||
saveFilterConfig();
|
||||
});
|
||||
|
||||
filterModeRadioGroup.setOnCheckedChangeListener((group, checkedId) -> {
|
||||
if (checkedId == R.id.allowListRadio) {
|
||||
filterConfig.setMode(SMSFilterHelper.FilterMode.ALLOW_LIST);
|
||||
} else {
|
||||
filterConfig.setMode(SMSFilterHelper.FilterMode.BLOCK_LIST);
|
||||
}
|
||||
saveFilterConfig();
|
||||
});
|
||||
|
||||
addRuleFab.setOnClickListener(v -> showAddEditRuleDialog(-1));
|
||||
}
|
||||
|
||||
private void saveFilterConfig() {
|
||||
SMSFilterHelper.saveFilterConfig(mContext, filterConfig);
|
||||
}
|
||||
|
||||
private void showAddEditRuleDialog(int position) {
|
||||
AlertDialog.Builder builder = new AlertDialog.Builder(this);
|
||||
View dialogView = LayoutInflater.from(this).inflate(R.layout.dialog_add_filter_rule, null);
|
||||
builder.setView(dialogView);
|
||||
|
||||
TextInputEditText patternEditText = dialogView.findViewById(R.id.patternEditText);
|
||||
Spinner filterTargetSpinner = dialogView.findViewById(R.id.filterTargetSpinner);
|
||||
Spinner matchTypeSpinner = dialogView.findViewById(R.id.matchTypeSpinner);
|
||||
Switch caseSensitiveSwitch = dialogView.findViewById(R.id.caseSensitiveSwitch);
|
||||
Button cancelButton = dialogView.findViewById(R.id.cancelButton);
|
||||
Button saveButton = dialogView.findViewById(R.id.saveButton);
|
||||
|
||||
// Setup filter target spinner
|
||||
String[] filterTargets = {"Sender", "Message", "Both"};
|
||||
ArrayAdapter<String> targetAdapter = new ArrayAdapter<>(this, android.R.layout.simple_spinner_item, filterTargets);
|
||||
targetAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
|
||||
filterTargetSpinner.setAdapter(targetAdapter);
|
||||
|
||||
// Setup match type spinner
|
||||
String[] matchTypes = {"Exact Match", "Starts With", "Ends With", "Contains"};
|
||||
ArrayAdapter<String> spinnerAdapter = new ArrayAdapter<>(this, android.R.layout.simple_spinner_item, matchTypes);
|
||||
spinnerAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
|
||||
matchTypeSpinner.setAdapter(spinnerAdapter);
|
||||
|
||||
// If editing, populate fields
|
||||
boolean isEdit = position >= 0;
|
||||
TextView dialogTitle = dialogView.findViewById(R.id.dialogTitle);
|
||||
if (isEdit) {
|
||||
SMSFilterRule rule = filterConfig.getRules().get(position);
|
||||
patternEditText.setText(rule.getPattern());
|
||||
filterTargetSpinner.setSelection(rule.getFilterTarget().ordinal());
|
||||
matchTypeSpinner.setSelection(rule.getMatchType().ordinal());
|
||||
caseSensitiveSwitch.setChecked(rule.isCaseSensitive());
|
||||
if (dialogTitle != null) {
|
||||
dialogTitle.setText("Edit Filter Rule");
|
||||
}
|
||||
} else {
|
||||
// Default to case insensitive
|
||||
caseSensitiveSwitch.setChecked(false);
|
||||
}
|
||||
|
||||
AlertDialog dialog = builder.create();
|
||||
|
||||
cancelButton.setOnClickListener(v -> dialog.dismiss());
|
||||
|
||||
saveButton.setOnClickListener(v -> {
|
||||
String pattern = patternEditText.getText() != null ? patternEditText.getText().toString().trim() : "";
|
||||
if (pattern.isEmpty()) {
|
||||
Toast.makeText(this, "Please enter a pattern", Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
|
||||
SMSFilterRule.FilterTarget filterTarget = SMSFilterRule.FilterTarget.values()[filterTargetSpinner.getSelectedItemPosition()];
|
||||
SMSFilterRule.MatchType matchType = SMSFilterRule.MatchType.values()[matchTypeSpinner.getSelectedItemPosition()];
|
||||
boolean caseSensitive = caseSensitiveSwitch.isChecked();
|
||||
|
||||
if (isEdit) {
|
||||
SMSFilterRule rule = filterConfig.getRules().get(position);
|
||||
rule.setPattern(pattern);
|
||||
rule.setFilterTarget(filterTarget);
|
||||
rule.setMatchType(matchType);
|
||||
rule.setCaseSensitive(caseSensitive);
|
||||
adapter.notifyItemChanged(position);
|
||||
} else {
|
||||
SMSFilterRule newRule = new SMSFilterRule(pattern, matchType, filterTarget, caseSensitive);
|
||||
filterConfig.getRules().add(newRule);
|
||||
adapter.notifyItemInserted(filterConfig.getRules().size() - 1);
|
||||
}
|
||||
|
||||
saveFilterConfig();
|
||||
dialog.dismiss();
|
||||
});
|
||||
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
private void deleteRule(int position) {
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("Delete Rule")
|
||||
.setMessage("Are you sure you want to delete this filter rule?")
|
||||
.setPositiveButton("Delete", (dialog, which) -> {
|
||||
filterConfig.getRules().remove(position);
|
||||
adapter.notifyItemRemoved(position);
|
||||
saveFilterConfig();
|
||||
})
|
||||
.setNegativeButton("Cancel", null)
|
||||
.show();
|
||||
}
|
||||
|
||||
private class FilterRulesAdapter extends RecyclerView.Adapter<FilterRulesAdapter.ViewHolder> {
|
||||
private List<SMSFilterRule> rules;
|
||||
|
||||
public FilterRulesAdapter(List<SMSFilterRule> rules) {
|
||||
this.rules = rules != null ? rules : new ArrayList<>();
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||
View view = LayoutInflater.from(parent.getContext())
|
||||
.inflate(R.layout.item_filter_rule, parent, false);
|
||||
return new ViewHolder(view);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
|
||||
SMSFilterRule rule = rules.get(position);
|
||||
holder.patternText.setText(rule.getPattern());
|
||||
|
||||
String matchTypeText = "";
|
||||
switch (rule.getMatchType()) {
|
||||
case EXACT:
|
||||
matchTypeText = "Exact Match";
|
||||
break;
|
||||
case STARTS_WITH:
|
||||
matchTypeText = "Starts With";
|
||||
break;
|
||||
case ENDS_WITH:
|
||||
matchTypeText = "Ends With";
|
||||
break;
|
||||
case CONTAINS:
|
||||
matchTypeText = "Contains";
|
||||
break;
|
||||
}
|
||||
holder.matchTypeText.setText(matchTypeText);
|
||||
|
||||
String filterTargetText = "";
|
||||
switch (rule.getFilterTarget()) {
|
||||
case SENDER:
|
||||
filterTargetText = "Filter: Sender";
|
||||
break;
|
||||
case MESSAGE:
|
||||
filterTargetText = "Filter: Message";
|
||||
break;
|
||||
case BOTH:
|
||||
filterTargetText = "Filter: Sender or Message";
|
||||
break;
|
||||
}
|
||||
String caseText = rule.isCaseSensitive() ? " (Case Sensitive)" : " (Case Insensitive)";
|
||||
holder.filterTargetText.setText(filterTargetText + caseText);
|
||||
|
||||
holder.editButton.setOnClickListener(v -> showAddEditRuleDialog(position));
|
||||
holder.deleteButton.setOnClickListener(v -> deleteRule(position));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return rules.size();
|
||||
}
|
||||
|
||||
class ViewHolder extends RecyclerView.ViewHolder {
|
||||
TextView patternText;
|
||||
TextView matchTypeText;
|
||||
TextView filterTargetText;
|
||||
ImageButton editButton;
|
||||
ImageButton deleteButton;
|
||||
|
||||
ViewHolder(View itemView) {
|
||||
super(itemView);
|
||||
patternText = itemView.findViewById(R.id.patternText);
|
||||
matchTypeText = itemView.findViewById(R.id.matchTypeText);
|
||||
filterTargetText = itemView.findViewById(R.id.filterTargetText);
|
||||
editButton = itemView.findViewById(R.id.editButton);
|
||||
deleteButton = itemView.findViewById(R.id.deleteButton);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package com.vernu.sms.dtos;
|
||||
|
||||
public class HeartbeatInputDTO {
|
||||
private String fcmToken;
|
||||
private Integer batteryPercentage;
|
||||
private Boolean isCharging;
|
||||
private String networkType;
|
||||
private String appVersionName;
|
||||
private Integer appVersionCode;
|
||||
private Long deviceUptimeMillis;
|
||||
private Long memoryFreeBytes;
|
||||
private Long memoryTotalBytes;
|
||||
private Long memoryMaxBytes;
|
||||
private Long storageAvailableBytes;
|
||||
private Long storageTotalBytes;
|
||||
private String timezone;
|
||||
private String locale;
|
||||
private Boolean receiveSMSEnabled;
|
||||
private SimInfoCollectionDTO simInfo;
|
||||
|
||||
public HeartbeatInputDTO() {
|
||||
}
|
||||
|
||||
public String getFcmToken() {
|
||||
return fcmToken;
|
||||
}
|
||||
|
||||
public void setFcmToken(String fcmToken) {
|
||||
this.fcmToken = fcmToken;
|
||||
}
|
||||
|
||||
public Integer getBatteryPercentage() {
|
||||
return batteryPercentage;
|
||||
}
|
||||
|
||||
public void setBatteryPercentage(Integer batteryPercentage) {
|
||||
this.batteryPercentage = batteryPercentage;
|
||||
}
|
||||
|
||||
public Boolean getIsCharging() {
|
||||
return isCharging;
|
||||
}
|
||||
|
||||
public void setIsCharging(Boolean isCharging) {
|
||||
this.isCharging = isCharging;
|
||||
}
|
||||
|
||||
public String getNetworkType() {
|
||||
return networkType;
|
||||
}
|
||||
|
||||
public void setNetworkType(String networkType) {
|
||||
this.networkType = networkType;
|
||||
}
|
||||
|
||||
public String getAppVersionName() {
|
||||
return appVersionName;
|
||||
}
|
||||
|
||||
public void setAppVersionName(String appVersionName) {
|
||||
this.appVersionName = appVersionName;
|
||||
}
|
||||
|
||||
public Integer getAppVersionCode() {
|
||||
return appVersionCode;
|
||||
}
|
||||
|
||||
public void setAppVersionCode(Integer appVersionCode) {
|
||||
this.appVersionCode = appVersionCode;
|
||||
}
|
||||
|
||||
public Long getDeviceUptimeMillis() {
|
||||
return deviceUptimeMillis;
|
||||
}
|
||||
|
||||
public void setDeviceUptimeMillis(Long deviceUptimeMillis) {
|
||||
this.deviceUptimeMillis = deviceUptimeMillis;
|
||||
}
|
||||
|
||||
public Long getMemoryFreeBytes() {
|
||||
return memoryFreeBytes;
|
||||
}
|
||||
|
||||
public void setMemoryFreeBytes(Long memoryFreeBytes) {
|
||||
this.memoryFreeBytes = memoryFreeBytes;
|
||||
}
|
||||
|
||||
public Long getMemoryTotalBytes() {
|
||||
return memoryTotalBytes;
|
||||
}
|
||||
|
||||
public void setMemoryTotalBytes(Long memoryTotalBytes) {
|
||||
this.memoryTotalBytes = memoryTotalBytes;
|
||||
}
|
||||
|
||||
public Long getMemoryMaxBytes() {
|
||||
return memoryMaxBytes;
|
||||
}
|
||||
|
||||
public void setMemoryMaxBytes(Long memoryMaxBytes) {
|
||||
this.memoryMaxBytes = memoryMaxBytes;
|
||||
}
|
||||
|
||||
public Long getStorageAvailableBytes() {
|
||||
return storageAvailableBytes;
|
||||
}
|
||||
|
||||
public void setStorageAvailableBytes(Long storageAvailableBytes) {
|
||||
this.storageAvailableBytes = storageAvailableBytes;
|
||||
}
|
||||
|
||||
public Long getStorageTotalBytes() {
|
||||
return storageTotalBytes;
|
||||
}
|
||||
|
||||
public void setStorageTotalBytes(Long storageTotalBytes) {
|
||||
this.storageTotalBytes = storageTotalBytes;
|
||||
}
|
||||
|
||||
public String getTimezone() {
|
||||
return timezone;
|
||||
}
|
||||
|
||||
public void setTimezone(String timezone) {
|
||||
this.timezone = timezone;
|
||||
}
|
||||
|
||||
public String getLocale() {
|
||||
return locale;
|
||||
}
|
||||
|
||||
public void setLocale(String locale) {
|
||||
this.locale = locale;
|
||||
}
|
||||
|
||||
public Boolean getReceiveSMSEnabled() {
|
||||
return receiveSMSEnabled;
|
||||
}
|
||||
|
||||
public void setReceiveSMSEnabled(Boolean receiveSMSEnabled) {
|
||||
this.receiveSMSEnabled = receiveSMSEnabled;
|
||||
}
|
||||
|
||||
public SimInfoCollectionDTO getSimInfo() {
|
||||
return simInfo;
|
||||
}
|
||||
|
||||
public void setSimInfo(SimInfoCollectionDTO simInfo) {
|
||||
this.simInfo = simInfo;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.vernu.sms.dtos;
|
||||
|
||||
public class HeartbeatResponseDTO {
|
||||
public boolean success;
|
||||
public boolean fcmTokenUpdated;
|
||||
public long lastHeartbeat;
|
||||
public String name;
|
||||
}
|
||||
@@ -6,12 +6,14 @@ public class RegisterDeviceInputDTO {
|
||||
private String brand;
|
||||
private String manufacturer;
|
||||
private String model;
|
||||
private String name;
|
||||
private String serial;
|
||||
private String buildId;
|
||||
private String os;
|
||||
private String osVersion;
|
||||
private String appVersionName;
|
||||
private int appVersionCode;
|
||||
private SimInfoCollectionDTO simInfo;
|
||||
|
||||
public RegisterDeviceInputDTO() {
|
||||
}
|
||||
@@ -60,6 +62,14 @@ public class RegisterDeviceInputDTO {
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public String getSerial() {
|
||||
return serial;
|
||||
}
|
||||
@@ -107,4 +117,12 @@ public class RegisterDeviceInputDTO {
|
||||
public void setAppVersionCode(int appVersionCode) {
|
||||
this.appVersionCode = appVersionCode;
|
||||
}
|
||||
|
||||
public SimInfoCollectionDTO getSimInfo() {
|
||||
return simInfo;
|
||||
}
|
||||
|
||||
public void setSimInfo(SimInfoCollectionDTO simInfo) {
|
||||
this.simInfo = simInfo;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ public class SMSDTO {
|
||||
private String sender;
|
||||
private String message = "";
|
||||
private long receivedAtInMillis;
|
||||
private String fingerprint;
|
||||
|
||||
private String smsId;
|
||||
private String smsBatchId;
|
||||
@@ -107,4 +108,12 @@ public class SMSDTO {
|
||||
public void setErrorMessage(String errorMessage) {
|
||||
this.errorMessage = errorMessage;
|
||||
}
|
||||
|
||||
public String getFingerprint() {
|
||||
return fingerprint;
|
||||
}
|
||||
|
||||
public void setFingerprint(String fingerprint) {
|
||||
this.fingerprint = fingerprint;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.vernu.sms.dtos;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class SimInfoCollectionDTO {
|
||||
private long lastUpdated;
|
||||
private List<SimInfoDTO> sims;
|
||||
|
||||
public SimInfoCollectionDTO() {
|
||||
}
|
||||
|
||||
public long getLastUpdated() {
|
||||
return lastUpdated;
|
||||
}
|
||||
|
||||
public void setLastUpdated(long lastUpdated) {
|
||||
this.lastUpdated = lastUpdated;
|
||||
}
|
||||
|
||||
public List<SimInfoDTO> getSims() {
|
||||
return sims;
|
||||
}
|
||||
|
||||
public void setSims(List<SimInfoDTO> sims) {
|
||||
this.sims = sims;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.vernu.sms.dtos;
|
||||
|
||||
public class SimInfoDTO {
|
||||
private int subscriptionId;
|
||||
private String iccId;
|
||||
private Integer cardId;
|
||||
private String carrierName;
|
||||
private String displayName;
|
||||
private Integer simSlotIndex;
|
||||
private String mcc;
|
||||
private String mnc;
|
||||
private String countryIso;
|
||||
private String subscriptionType;
|
||||
|
||||
public SimInfoDTO() {
|
||||
}
|
||||
|
||||
public int getSubscriptionId() {
|
||||
return subscriptionId;
|
||||
}
|
||||
|
||||
public void setSubscriptionId(int subscriptionId) {
|
||||
this.subscriptionId = subscriptionId;
|
||||
}
|
||||
|
||||
public String getIccId() {
|
||||
return iccId;
|
||||
}
|
||||
|
||||
public void setIccId(String iccId) {
|
||||
this.iccId = iccId;
|
||||
}
|
||||
|
||||
public Integer getCardId() {
|
||||
return cardId;
|
||||
}
|
||||
|
||||
public void setCardId(Integer cardId) {
|
||||
this.cardId = cardId;
|
||||
}
|
||||
|
||||
public String getCarrierName() {
|
||||
return carrierName;
|
||||
}
|
||||
|
||||
public void setCarrierName(String carrierName) {
|
||||
this.carrierName = carrierName;
|
||||
}
|
||||
|
||||
public String getDisplayName() {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
public void setDisplayName(String displayName) {
|
||||
this.displayName = displayName;
|
||||
}
|
||||
|
||||
public Integer getSimSlotIndex() {
|
||||
return simSlotIndex;
|
||||
}
|
||||
|
||||
public void setSimSlotIndex(Integer simSlotIndex) {
|
||||
this.simSlotIndex = simSlotIndex;
|
||||
}
|
||||
|
||||
public String getMcc() {
|
||||
return mcc;
|
||||
}
|
||||
|
||||
public void setMcc(String mcc) {
|
||||
this.mcc = mcc;
|
||||
}
|
||||
|
||||
public String getMnc() {
|
||||
return mnc;
|
||||
}
|
||||
|
||||
public void setMnc(String mnc) {
|
||||
this.mnc = mnc;
|
||||
}
|
||||
|
||||
public String getCountryIso() {
|
||||
return countryIso;
|
||||
}
|
||||
|
||||
public void setCountryIso(String countryIso) {
|
||||
this.countryIso = countryIso;
|
||||
}
|
||||
|
||||
public String getSubscriptionType() {
|
||||
return subscriptionType;
|
||||
}
|
||||
|
||||
public void setSubscriptionType(String subscriptionType) {
|
||||
this.subscriptionType = subscriptionType;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package com.vernu.sms.helpers;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.net.ConnectivityManager;
|
||||
import android.net.NetworkInfo;
|
||||
import android.os.BatteryManager;
|
||||
import android.os.StatFs;
|
||||
import android.os.SystemClock;
|
||||
import android.util.Log;
|
||||
|
||||
import com.google.firebase.messaging.FirebaseMessaging;
|
||||
import com.vernu.sms.ApiManager;
|
||||
import com.vernu.sms.AppConstants;
|
||||
import com.vernu.sms.BuildConfig;
|
||||
import com.vernu.sms.dtos.HeartbeatInputDTO;
|
||||
import com.vernu.sms.dtos.HeartbeatResponseDTO;
|
||||
import com.vernu.sms.dtos.SimInfoCollectionDTO;
|
||||
import com.vernu.sms.TextBeeUtils;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.Locale;
|
||||
import java.util.TimeZone;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import retrofit2.Call;
|
||||
import retrofit2.Response;
|
||||
|
||||
public class HeartbeatHelper {
|
||||
private static final String TAG = "HeartbeatHelper";
|
||||
|
||||
/**
|
||||
* Collects device information and sends a heartbeat request to the API.
|
||||
*
|
||||
* @param context Application context
|
||||
* @param deviceId Device ID
|
||||
* @param apiKey API key for authentication
|
||||
* @return true if heartbeat was sent successfully, false otherwise
|
||||
*/
|
||||
public static boolean sendHeartbeat(Context context, String deviceId, String apiKey) {
|
||||
if (deviceId == null || deviceId.isEmpty()) {
|
||||
Log.d(TAG, "Device not registered, skipping heartbeat");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (apiKey == null || apiKey.isEmpty()) {
|
||||
Log.e(TAG, "API key not available, skipping heartbeat");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Collect device information
|
||||
HeartbeatInputDTO heartbeatInput = new HeartbeatInputDTO();
|
||||
|
||||
try {
|
||||
// Get FCM token (blocking wait)
|
||||
try {
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
final String[] fcmToken = new String[1];
|
||||
FirebaseMessaging.getInstance().getToken().addOnCompleteListener(task -> {
|
||||
if (task.isSuccessful()) {
|
||||
fcmToken[0] = task.getResult();
|
||||
}
|
||||
latch.countDown();
|
||||
});
|
||||
if (latch.await(5, TimeUnit.SECONDS) && fcmToken[0] != null) {
|
||||
heartbeatInput.setFcmToken(fcmToken[0]);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Failed to get FCM token: " + e.getMessage());
|
||||
// Continue without FCM token
|
||||
}
|
||||
|
||||
// Get battery information
|
||||
IntentFilter ifilter = new IntentFilter(Intent.ACTION_BATTERY_CHANGED);
|
||||
Intent batteryStatus = context.registerReceiver(null, ifilter);
|
||||
if (batteryStatus != null) {
|
||||
int level = batteryStatus.getIntExtra(BatteryManager.EXTRA_LEVEL, -1);
|
||||
int scale = batteryStatus.getIntExtra(BatteryManager.EXTRA_SCALE, -1);
|
||||
int batteryPct = (int) ((level / (float) scale) * 100);
|
||||
heartbeatInput.setBatteryPercentage(batteryPct);
|
||||
|
||||
int status = batteryStatus.getIntExtra(BatteryManager.EXTRA_STATUS, -1);
|
||||
boolean isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING ||
|
||||
status == BatteryManager.BATTERY_STATUS_FULL;
|
||||
heartbeatInput.setIsCharging(isCharging);
|
||||
}
|
||||
|
||||
// Get network type
|
||||
ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
|
||||
if (cm != null) {
|
||||
NetworkInfo activeNetwork = cm.getActiveNetworkInfo();
|
||||
if (activeNetwork != null && activeNetwork.isConnected()) {
|
||||
if (activeNetwork.getType() == ConnectivityManager.TYPE_WIFI) {
|
||||
heartbeatInput.setNetworkType("wifi");
|
||||
} else if (activeNetwork.getType() == ConnectivityManager.TYPE_MOBILE) {
|
||||
heartbeatInput.setNetworkType("cellular");
|
||||
} else {
|
||||
heartbeatInput.setNetworkType("none");
|
||||
}
|
||||
} else {
|
||||
heartbeatInput.setNetworkType("none");
|
||||
}
|
||||
}
|
||||
|
||||
// Get app version
|
||||
heartbeatInput.setAppVersionName(BuildConfig.VERSION_NAME);
|
||||
heartbeatInput.setAppVersionCode(BuildConfig.VERSION_CODE);
|
||||
|
||||
// Get device uptime
|
||||
heartbeatInput.setDeviceUptimeMillis(SystemClock.uptimeMillis());
|
||||
|
||||
// Get memory information
|
||||
Runtime runtime = Runtime.getRuntime();
|
||||
heartbeatInput.setMemoryFreeBytes(runtime.freeMemory());
|
||||
heartbeatInput.setMemoryTotalBytes(runtime.totalMemory());
|
||||
heartbeatInput.setMemoryMaxBytes(runtime.maxMemory());
|
||||
|
||||
// Get storage information
|
||||
File internalStorage = context.getFilesDir();
|
||||
StatFs statFs = new StatFs(internalStorage.getPath());
|
||||
long availableBytes = statFs.getAvailableBytes();
|
||||
long totalBytes = statFs.getTotalBytes();
|
||||
heartbeatInput.setStorageAvailableBytes(availableBytes);
|
||||
heartbeatInput.setStorageTotalBytes(totalBytes);
|
||||
|
||||
// Get system information
|
||||
heartbeatInput.setTimezone(TimeZone.getDefault().getID());
|
||||
heartbeatInput.setLocale(Locale.getDefault().toString());
|
||||
|
||||
// Get receive SMS enabled status
|
||||
boolean receiveSMSEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
|
||||
context,
|
||||
AppConstants.SHARED_PREFS_RECEIVE_SMS_ENABLED_KEY,
|
||||
false
|
||||
);
|
||||
heartbeatInput.setReceiveSMSEnabled(receiveSMSEnabled);
|
||||
|
||||
// Collect SIM information
|
||||
SimInfoCollectionDTO simInfoCollection = new SimInfoCollectionDTO();
|
||||
simInfoCollection.setLastUpdated(System.currentTimeMillis());
|
||||
simInfoCollection.setSims(TextBeeUtils.collectSimInfo(context));
|
||||
heartbeatInput.setSimInfo(simInfoCollection);
|
||||
|
||||
// Send heartbeat request
|
||||
Call<HeartbeatResponseDTO> call = ApiManager.getApiService().heartbeat(deviceId, apiKey, heartbeatInput);
|
||||
Response<HeartbeatResponseDTO> response = call.execute();
|
||||
|
||||
if (response.isSuccessful() && response.body() != null) {
|
||||
HeartbeatResponseDTO responseBody = response.body();
|
||||
if (responseBody.fcmTokenUpdated) {
|
||||
Log.d(TAG, "FCM token was updated during heartbeat");
|
||||
}
|
||||
|
||||
// Sync device name from heartbeat response (ignore if blank)
|
||||
if (responseBody.name != null && !responseBody.name.trim().isEmpty()) {
|
||||
SharedPreferenceHelper.setSharedPreferenceString(
|
||||
context,
|
||||
AppConstants.SHARED_PREFS_DEVICE_NAME_KEY,
|
||||
responseBody.name
|
||||
);
|
||||
Log.d(TAG, "Synced device name from heartbeat: " + responseBody.name);
|
||||
}
|
||||
|
||||
Log.d(TAG, "Heartbeat sent successfully");
|
||||
return true;
|
||||
} else {
|
||||
Log.e(TAG, "Failed to send heartbeat. Response code: " + (response.code()));
|
||||
return false;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Log.e(TAG, "Heartbeat API call failed: " + e.getMessage());
|
||||
return false;
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error collecting device information: " + e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if device is eligible to send heartbeat (registered, enabled, heartbeat enabled).
|
||||
*
|
||||
* @param context Application context
|
||||
* @return true if device is eligible, false otherwise
|
||||
*/
|
||||
public static boolean isDeviceEligibleForHeartbeat(Context context) {
|
||||
// Check if device is registered
|
||||
String deviceId = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context,
|
||||
AppConstants.SHARED_PREFS_DEVICE_ID_KEY,
|
||||
""
|
||||
);
|
||||
|
||||
if (deviceId.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if device is enabled
|
||||
boolean deviceEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
|
||||
context,
|
||||
AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY,
|
||||
false
|
||||
);
|
||||
|
||||
if (!deviceEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if heartbeat feature is enabled
|
||||
boolean heartbeatEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
|
||||
context,
|
||||
AppConstants.SHARED_PREFS_HEARTBEAT_ENABLED_KEY,
|
||||
true // Default to true
|
||||
);
|
||||
|
||||
return heartbeatEnabled;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.vernu.sms.helpers;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.work.Constraints;
|
||||
import androidx.work.ExistingPeriodicWorkPolicy;
|
||||
import androidx.work.NetworkType;
|
||||
import androidx.work.PeriodicWorkRequest;
|
||||
import androidx.work.WorkManager;
|
||||
|
||||
import com.vernu.sms.AppConstants;
|
||||
import com.vernu.sms.workers.HeartbeatWorker;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class HeartbeatManager {
|
||||
private static final String TAG = "HeartbeatManager";
|
||||
private static final int MIN_INTERVAL_MINUTES = 15; // Android WorkManager minimum
|
||||
private static final String UNIQUE_WORK_NAME = "heartbeat_unique_work";
|
||||
|
||||
public static void scheduleHeartbeat(Context context) {
|
||||
// Use application context to ensure WorkManager works even when app is closed
|
||||
Context appContext = context.getApplicationContext();
|
||||
|
||||
// Get interval from shared preferences (default 30 minutes)
|
||||
int intervalMinutes = SharedPreferenceHelper.getSharedPreferenceInt(
|
||||
appContext,
|
||||
AppConstants.SHARED_PREFS_HEARTBEAT_INTERVAL_MINUTES_KEY,
|
||||
30
|
||||
);
|
||||
|
||||
// Enforce minimum interval
|
||||
if (intervalMinutes < MIN_INTERVAL_MINUTES) {
|
||||
Log.w(TAG, "Interval " + intervalMinutes + " minutes is less than minimum " + MIN_INTERVAL_MINUTES + " minutes, using minimum");
|
||||
intervalMinutes = MIN_INTERVAL_MINUTES;
|
||||
}
|
||||
|
||||
Log.d(TAG, "Scheduling heartbeat with interval: " + intervalMinutes + " minutes");
|
||||
|
||||
// Create constraints
|
||||
Constraints constraints = new Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build();
|
||||
|
||||
// Create periodic work request
|
||||
PeriodicWorkRequest heartbeatWork = new PeriodicWorkRequest.Builder(
|
||||
HeartbeatWorker.class,
|
||||
intervalMinutes,
|
||||
TimeUnit.MINUTES
|
||||
)
|
||||
.setConstraints(constraints)
|
||||
.addTag(AppConstants.HEARTBEAT_WORK_TAG)
|
||||
.build();
|
||||
|
||||
// Use enqueueUniquePeriodicWork to ensure only one periodic work exists
|
||||
// This ensures the work persists across app restarts and device reboots
|
||||
WorkManager.getInstance(appContext)
|
||||
.enqueueUniquePeriodicWork(
|
||||
UNIQUE_WORK_NAME,
|
||||
ExistingPeriodicWorkPolicy.REPLACE,
|
||||
heartbeatWork
|
||||
);
|
||||
|
||||
Log.d(TAG, "Heartbeat scheduled successfully with unique work name: " + UNIQUE_WORK_NAME);
|
||||
}
|
||||
|
||||
public static void cancelHeartbeat(Context context) {
|
||||
Log.d(TAG, "Cancelling heartbeat work");
|
||||
Context appContext = context.getApplicationContext();
|
||||
|
||||
// Cancel by unique work name (more reliable)
|
||||
WorkManager.getInstance(appContext)
|
||||
.cancelUniqueWork(UNIQUE_WORK_NAME);
|
||||
|
||||
// Also cancel by tag as fallback
|
||||
WorkManager.getInstance(appContext)
|
||||
.cancelAllWorkByTag(AppConstants.HEARTBEAT_WORK_TAG);
|
||||
}
|
||||
|
||||
public static void triggerHeartbeat(Context context) {
|
||||
// This can be used for testing - trigger immediate heartbeat
|
||||
Log.d(TAG, "Triggering immediate heartbeat");
|
||||
// For immediate execution, we could create a OneTimeWorkRequest
|
||||
// but for now, just reschedule which will run soon
|
||||
scheduleHeartbeat(context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package com.vernu.sms.helpers;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import com.vernu.sms.AppConstants;
|
||||
import com.vernu.sms.models.SMSFilterRule;
|
||||
|
||||
import java.lang.reflect.Type;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class SMSFilterHelper {
|
||||
private static final String TAG = "SMSFilterHelper";
|
||||
|
||||
public enum FilterMode {
|
||||
ALLOW_LIST,
|
||||
BLOCK_LIST
|
||||
}
|
||||
|
||||
public static class FilterConfig {
|
||||
private boolean enabled = false;
|
||||
private FilterMode mode = FilterMode.BLOCK_LIST; // Default to block list
|
||||
private List<SMSFilterRule> rules = new ArrayList<>();
|
||||
|
||||
public FilterConfig() {
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public FilterMode getMode() {
|
||||
return mode;
|
||||
}
|
||||
|
||||
public void setMode(FilterMode mode) {
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
public List<SMSFilterRule> getRules() {
|
||||
return rules;
|
||||
}
|
||||
|
||||
public void setRules(List<SMSFilterRule> rules) {
|
||||
this.rules = rules != null ? rules : new ArrayList<>();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load filter configuration from SharedPreferences
|
||||
*/
|
||||
public static FilterConfig loadFilterConfig(Context context) {
|
||||
String json = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context,
|
||||
AppConstants.SHARED_PREFS_SMS_FILTER_CONFIG_KEY,
|
||||
null
|
||||
);
|
||||
|
||||
if (json == null || json.isEmpty()) {
|
||||
return new FilterConfig();
|
||||
}
|
||||
|
||||
try {
|
||||
Gson gson = new Gson();
|
||||
Type type = new TypeToken<FilterConfig>() {}.getType();
|
||||
FilterConfig config = gson.fromJson(json, type);
|
||||
return config != null ? config : new FilterConfig();
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error loading filter config: " + e.getMessage());
|
||||
return new FilterConfig();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save filter configuration to SharedPreferences
|
||||
*/
|
||||
public static void saveFilterConfig(Context context, FilterConfig config) {
|
||||
try {
|
||||
Gson gson = new Gson();
|
||||
String json = gson.toJson(config);
|
||||
SharedPreferenceHelper.setSharedPreferenceString(
|
||||
context,
|
||||
AppConstants.SHARED_PREFS_SMS_FILTER_CONFIG_KEY,
|
||||
json
|
||||
);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error saving filter config: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an SMS should be processed based on filter configuration
|
||||
* @param sender The sender phone number
|
||||
* @param message The message content
|
||||
* @param context Application context
|
||||
* @return true if SMS should be processed, false if it should be filtered out
|
||||
*/
|
||||
public static boolean shouldProcessSMS(String sender, String message, Context context) {
|
||||
FilterConfig config = loadFilterConfig(context);
|
||||
|
||||
// If filter is disabled, process all SMS
|
||||
if (!config.isEnabled()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If no rules, process all SMS (empty filter doesn't block anything)
|
||||
if (config.getRules() == null || config.getRules().isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if sender and/or message matches any rule
|
||||
boolean matchesAnyRule = false;
|
||||
for (SMSFilterRule rule : config.getRules()) {
|
||||
if (rule.matches(sender, message)) {
|
||||
matchesAnyRule = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply filter mode
|
||||
if (config.getMode() == FilterMode.ALLOW_LIST) {
|
||||
// Only process if matches a rule
|
||||
return matchesAnyRule;
|
||||
} else {
|
||||
// Block list: process if it does NOT match any rule
|
||||
return !matchesAnyRule;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy method for backward compatibility - checks sender only
|
||||
*/
|
||||
public static boolean shouldProcessSMS(String sender, Context context) {
|
||||
return shouldProcessSMS(sender, null, context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package com.vernu.sms.models;
|
||||
|
||||
public class SMSFilterRule {
|
||||
public enum MatchType {
|
||||
EXACT,
|
||||
STARTS_WITH,
|
||||
ENDS_WITH,
|
||||
CONTAINS
|
||||
}
|
||||
|
||||
public enum FilterTarget {
|
||||
SENDER,
|
||||
MESSAGE,
|
||||
BOTH
|
||||
}
|
||||
|
||||
private String pattern;
|
||||
private MatchType matchType;
|
||||
private FilterTarget filterTarget = FilterTarget.SENDER; // Default to sender for backward compatibility
|
||||
private boolean caseSensitive = false; // Default to case insensitive
|
||||
|
||||
public SMSFilterRule() {
|
||||
}
|
||||
|
||||
public SMSFilterRule(String pattern, MatchType matchType) {
|
||||
this.pattern = pattern;
|
||||
this.matchType = matchType;
|
||||
this.filterTarget = FilterTarget.SENDER;
|
||||
this.caseSensitive = false;
|
||||
}
|
||||
|
||||
public SMSFilterRule(String pattern, MatchType matchType, FilterTarget filterTarget) {
|
||||
this.pattern = pattern;
|
||||
this.matchType = matchType;
|
||||
this.filterTarget = filterTarget;
|
||||
this.caseSensitive = false;
|
||||
}
|
||||
|
||||
public SMSFilterRule(String pattern, MatchType matchType, FilterTarget filterTarget, boolean caseSensitive) {
|
||||
this.pattern = pattern;
|
||||
this.matchType = matchType;
|
||||
this.filterTarget = filterTarget;
|
||||
this.caseSensitive = caseSensitive;
|
||||
}
|
||||
|
||||
public String getPattern() {
|
||||
return pattern;
|
||||
}
|
||||
|
||||
public void setPattern(String pattern) {
|
||||
this.pattern = pattern;
|
||||
}
|
||||
|
||||
public MatchType getMatchType() {
|
||||
return matchType;
|
||||
}
|
||||
|
||||
public void setMatchType(MatchType matchType) {
|
||||
this.matchType = matchType;
|
||||
}
|
||||
|
||||
public FilterTarget getFilterTarget() {
|
||||
return filterTarget;
|
||||
}
|
||||
|
||||
public void setFilterTarget(FilterTarget filterTarget) {
|
||||
this.filterTarget = filterTarget != null ? filterTarget : FilterTarget.SENDER;
|
||||
}
|
||||
|
||||
public boolean isCaseSensitive() {
|
||||
return caseSensitive;
|
||||
}
|
||||
|
||||
public void setCaseSensitive(boolean caseSensitive) {
|
||||
this.caseSensitive = caseSensitive;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string matches this filter rule based on match type
|
||||
*/
|
||||
private boolean matchesString(String text) {
|
||||
if (pattern == null || text == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String patternToMatch = pattern;
|
||||
String textToMatch = text;
|
||||
|
||||
// Apply case sensitivity
|
||||
if (!caseSensitive) {
|
||||
patternToMatch = patternToMatch.toLowerCase();
|
||||
textToMatch = textToMatch.toLowerCase();
|
||||
}
|
||||
|
||||
switch (matchType) {
|
||||
case EXACT:
|
||||
return textToMatch.equals(patternToMatch);
|
||||
case STARTS_WITH:
|
||||
return textToMatch.startsWith(patternToMatch);
|
||||
case ENDS_WITH:
|
||||
return textToMatch.endsWith(patternToMatch);
|
||||
case CONTAINS:
|
||||
return textToMatch.contains(patternToMatch);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given sender and/or message matches this filter rule
|
||||
*/
|
||||
public boolean matches(String sender, String message) {
|
||||
if (pattern == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (filterTarget) {
|
||||
case SENDER:
|
||||
return matchesString(sender);
|
||||
case MESSAGE:
|
||||
return matchesString(message);
|
||||
case BOTH:
|
||||
return matchesString(sender) || matchesString(message);
|
||||
default:
|
||||
return matchesString(sender);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy method for backward compatibility - checks sender only
|
||||
*/
|
||||
public boolean matches(String sender) {
|
||||
return matches(sender, null);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ public class SMSPayload {
|
||||
private String message;
|
||||
private String smsId;
|
||||
private String smsBatchId;
|
||||
private Integer simSubscriptionId;
|
||||
|
||||
// Legacy fields that are no longer used
|
||||
private String[] receivers;
|
||||
@@ -45,4 +46,12 @@ public class SMSPayload {
|
||||
public void setSmsBatchId(String smsBatchId) {
|
||||
this.smsBatchId = smsBatchId;
|
||||
}
|
||||
|
||||
public Integer getSimSubscriptionId() {
|
||||
return simSubscriptionId;
|
||||
}
|
||||
|
||||
public void setSimSubscriptionId(Integer simSubscriptionId) {
|
||||
this.simSubscriptionId = simSubscriptionId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import com.vernu.sms.TextBeeUtils;
|
||||
import com.vernu.sms.dtos.RegisterDeviceInputDTO;
|
||||
import com.vernu.sms.dtos.RegisterDeviceResponseDTO;
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper;
|
||||
import com.vernu.sms.helpers.HeartbeatManager;
|
||||
import com.vernu.sms.services.StickyNotificationService;
|
||||
|
||||
import retrofit2.Call;
|
||||
@@ -54,6 +55,17 @@ public class BootCompletedReceiver extends BroadcastReceiver {
|
||||
// Only proceed if both device ID and API key are available
|
||||
if (!deviceId.isEmpty() && !apiKey.isEmpty()) {
|
||||
updateDeviceInfo(context, deviceId, apiKey);
|
||||
|
||||
// Schedule heartbeat if device is enabled
|
||||
boolean deviceEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
|
||||
context,
|
||||
AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY,
|
||||
false
|
||||
);
|
||||
if (deviceEnabled) {
|
||||
Log.i(TAG, "Device booted, scheduling heartbeat");
|
||||
HeartbeatManager.scheduleHeartbeat(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,6 +97,18 @@ public class BootCompletedReceiver extends BroadcastReceiver {
|
||||
public void onResponse(Call<RegisterDeviceResponseDTO> call, Response<RegisterDeviceResponseDTO> response) {
|
||||
if (response.isSuccessful()) {
|
||||
Log.d(TAG, "Device info updated successfully after boot");
|
||||
|
||||
// Sync heartbeatIntervalMinutes from server response
|
||||
if (response.body() != null && response.body().data != null) {
|
||||
if (response.body().data.get("heartbeatIntervalMinutes") != null) {
|
||||
Object intervalObj = response.body().data.get("heartbeatIntervalMinutes");
|
||||
if (intervalObj instanceof Number) {
|
||||
int intervalMinutes = ((Number) intervalObj).intValue();
|
||||
SharedPreferenceHelper.setSharedPreferenceInt(context, AppConstants.SHARED_PREFS_HEARTBEAT_INTERVAL_MINUTES_KEY, intervalMinutes);
|
||||
Log.d(TAG, "Synced heartbeat interval from server: " + intervalMinutes + " minutes");
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.e(TAG, "Failed to update device info after boot. Response code: " + response.code());
|
||||
}
|
||||
|
||||
@@ -9,13 +9,21 @@ import android.util.Log;
|
||||
import com.vernu.sms.AppConstants;
|
||||
import com.vernu.sms.dtos.SMSDTO;
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper;
|
||||
import com.vernu.sms.helpers.SMSFilterHelper;
|
||||
import com.vernu.sms.workers.SMSReceivedWorker;
|
||||
|
||||
import java.security.MessageDigest;
|
||||
import java.util.HashSet;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
|
||||
public class SMSBroadcastReceiver extends BroadcastReceiver {
|
||||
private static final String TAG = "SMSBroadcastReceiver";
|
||||
// In-memory cache to prevent rapid duplicate processing (5 seconds TTL)
|
||||
private static final ConcurrentHashMap<String, Long> processedFingerprints = new ConcurrentHashMap<>();
|
||||
private static final long CACHE_TTL_MS = 5000; // 5 seconds
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
@@ -60,6 +68,37 @@ public class SMSBroadcastReceiver extends BroadcastReceiver {
|
||||
// receivedSMSDTO.setMessage(receivedSMS.getMessage());
|
||||
// receivedSMSDTO.setReceivedAt(receivedSMS.getReceivedAt());
|
||||
|
||||
// Apply SMS filter
|
||||
String sender = receivedSMSDTO.getSender();
|
||||
String message = receivedSMSDTO.getMessage();
|
||||
if (sender != null && !SMSFilterHelper.shouldProcessSMS(sender, message, context)) {
|
||||
Log.d(TAG, "SMS from " + sender + " filtered out by filter rules");
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate fingerprint for deduplication
|
||||
String fingerprint = generateFingerprint(
|
||||
receivedSMSDTO.getSender(),
|
||||
receivedSMSDTO.getMessage(),
|
||||
receivedSMSDTO.getReceivedAtInMillis()
|
||||
);
|
||||
receivedSMSDTO.setFingerprint(fingerprint);
|
||||
|
||||
// Check in-memory cache to prevent rapid duplicate processing
|
||||
long currentTime = System.currentTimeMillis();
|
||||
Long lastProcessedTime = processedFingerprints.get(fingerprint);
|
||||
|
||||
if (lastProcessedTime != null && (currentTime - lastProcessedTime) < CACHE_TTL_MS) {
|
||||
Log.d(TAG, "Duplicate SMS detected in cache, skipping: " + fingerprint);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update cache
|
||||
processedFingerprints.put(fingerprint, currentTime);
|
||||
|
||||
// Clean up old cache entries periodically
|
||||
cleanupCache(currentTime);
|
||||
|
||||
SMSReceivedWorker.enqueueWork(context, deviceId, apiKey, receivedSMSDTO);
|
||||
}
|
||||
|
||||
@@ -69,4 +108,50 @@ public class SMSBroadcastReceiver extends BroadcastReceiver {
|
||||
// appDatabase.localReceivedSMSDao().insertAll(localReceivedSMS);
|
||||
// });
|
||||
// }
|
||||
|
||||
/**
|
||||
* Generate a unique fingerprint for an SMS message based on sender, message content, and timestamp
|
||||
*/
|
||||
private String generateFingerprint(String sender, String message, long timestamp) {
|
||||
try {
|
||||
String data = (sender != null ? sender : "") + "|" +
|
||||
(message != null ? message : "") + "|" +
|
||||
timestamp;
|
||||
|
||||
MessageDigest md = MessageDigest.getInstance("MD5");
|
||||
byte[] hashBytes = md.digest(data.getBytes("UTF-8"));
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (byte b : hashBytes) {
|
||||
sb.append(String.format("%02x", b));
|
||||
}
|
||||
return sb.toString();
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error generating fingerprint: " + e.getMessage());
|
||||
// Fallback to simple string concatenation if MD5 fails
|
||||
return (sender != null ? sender : "") + "_" +
|
||||
(message != null ? message : "") + "_" +
|
||||
timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old cache entries to prevent memory leaks
|
||||
*/
|
||||
private void cleanupCache(long currentTime) {
|
||||
// Only cleanup occasionally (every 100 entries processed)
|
||||
if (processedFingerprints.size() > 100) {
|
||||
Set<String> keysToRemove = new HashSet<>();
|
||||
for (String key : processedFingerprints.keySet()) {
|
||||
Long timestamp = processedFingerprints.get(key);
|
||||
if (timestamp != null && (currentTime - timestamp) > CACHE_TTL_MS) {
|
||||
keysToRemove.add(key);
|
||||
}
|
||||
}
|
||||
for (String key : keysToRemove) {
|
||||
processedFingerprints.remove(key);
|
||||
}
|
||||
Log.d(TAG, "Cleaned up " + keysToRemove.size() + " expired cache entries");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,14 @@ public class SMSStatusReceiver extends BroadcastReceiver {
|
||||
smsDTO.setErrorMessage(errorMessage);
|
||||
Log.e(TAG, "SMS failed to send - ID: " + smsDTO.getSmsId() + ", Error code: " + resultCode + ", Error: " + errorMessage);
|
||||
break;
|
||||
case SmsManager.RESULT_NETWORK_ERROR:
|
||||
errorMessage = "Network error";
|
||||
smsDTO.setStatus("FAILED");
|
||||
smsDTO.setFailedAtInMillis(timestamp);
|
||||
smsDTO.setErrorCode(String.valueOf(resultCode));
|
||||
smsDTO.setErrorMessage(errorMessage);
|
||||
Log.e(TAG, "SMS failed to send - ID: " + smsDTO.getSmsId() + ", Error code: " + resultCode + ", Error: " + errorMessage);
|
||||
break;
|
||||
default:
|
||||
errorMessage = "Unknown error";
|
||||
smsDTO.setStatus("FAILED");
|
||||
|
||||
@@ -18,7 +18,10 @@ import com.vernu.sms.R;
|
||||
import com.vernu.sms.activities.MainActivity;
|
||||
import com.vernu.sms.helpers.SMSHelper;
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper;
|
||||
import com.vernu.sms.helpers.HeartbeatHelper;
|
||||
import com.vernu.sms.helpers.HeartbeatManager;
|
||||
import com.vernu.sms.models.SMSPayload;
|
||||
import com.vernu.sms.TextBeeUtils;
|
||||
import com.vernu.sms.dtos.RegisterDeviceInputDTO;
|
||||
import com.vernu.sms.dtos.RegisterDeviceResponseDTO;
|
||||
import com.vernu.sms.ApiManager;
|
||||
@@ -36,7 +39,16 @@ public class FCMService extends FirebaseMessagingService {
|
||||
Log.d(TAG, remoteMessage.getData().toString());
|
||||
|
||||
try {
|
||||
// Parse SMS payload data
|
||||
// Check message type first
|
||||
String messageType = remoteMessage.getData().get("type");
|
||||
|
||||
if ("heartbeat_check".equals(messageType)) {
|
||||
// Handle heartbeat check request from backend
|
||||
handleHeartbeatCheck();
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse SMS payload data (legacy handling)
|
||||
Gson gson = new Gson();
|
||||
SMSPayload smsPayload = gson.fromJson(remoteMessage.getData().get("smsData"), SMSPayload.class);
|
||||
|
||||
@@ -54,6 +66,45 @@ public class FCMService extends FirebaseMessagingService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle heartbeat check request from backend
|
||||
*/
|
||||
private void handleHeartbeatCheck() {
|
||||
Log.d(TAG, "Received heartbeat check request from backend");
|
||||
|
||||
// Check if device is eligible for heartbeat
|
||||
if (!HeartbeatHelper.isDeviceEligibleForHeartbeat(this)) {
|
||||
Log.d(TAG, "Device not eligible for heartbeat, skipping heartbeat check");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get device ID and API key
|
||||
String deviceId = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
this,
|
||||
AppConstants.SHARED_PREFS_DEVICE_ID_KEY,
|
||||
""
|
||||
);
|
||||
|
||||
String apiKey = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
this,
|
||||
AppConstants.SHARED_PREFS_API_KEY_KEY,
|
||||
""
|
||||
);
|
||||
|
||||
// Send heartbeat using shared helper
|
||||
boolean success = HeartbeatHelper.sendHeartbeat(this, deviceId, apiKey);
|
||||
|
||||
if (success) {
|
||||
Log.d(TAG, "Heartbeat sent successfully in response to backend check");
|
||||
// Ensure scheduled work is added if missing
|
||||
HeartbeatManager.scheduleHeartbeat(this);
|
||||
} else {
|
||||
Log.e(TAG, "Failed to send heartbeat in response to backend check");
|
||||
// Still try to ensure scheduled work is added
|
||||
HeartbeatManager.scheduleHeartbeat(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send SMS to recipients using the provided payload
|
||||
*/
|
||||
@@ -63,9 +114,35 @@ public class FCMService extends FirebaseMessagingService {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get preferred SIM
|
||||
int preferredSim = SharedPreferenceHelper.getSharedPreferenceInt(
|
||||
this, AppConstants.SHARED_PREFS_PREFERRED_SIM_KEY, -1);
|
||||
// Determine which SIM to use (priority: backend-provided > app preference > device default)
|
||||
Integer simSubscriptionId = null;
|
||||
|
||||
// First, check if backend provided a SIM subscription ID
|
||||
if (smsPayload.getSimSubscriptionId() != null) {
|
||||
int backendSimId = smsPayload.getSimSubscriptionId();
|
||||
// Validate that the subscription ID exists
|
||||
if (TextBeeUtils.isValidSubscriptionId(this, backendSimId)) {
|
||||
simSubscriptionId = backendSimId;
|
||||
Log.d(TAG, "Using backend-provided SIM subscription ID: " + backendSimId);
|
||||
} else {
|
||||
Log.w(TAG, "Backend-provided SIM subscription ID " + backendSimId + " is not valid, falling back to app preference");
|
||||
}
|
||||
}
|
||||
|
||||
// If backend didn't provide a valid SIM, check app preference
|
||||
if (simSubscriptionId == null) {
|
||||
int preferredSim = SharedPreferenceHelper.getSharedPreferenceInt(
|
||||
this, AppConstants.SHARED_PREFS_PREFERRED_SIM_KEY, -1);
|
||||
if (preferredSim != -1) {
|
||||
// Validate that the preferred SIM still exists
|
||||
if (TextBeeUtils.isValidSubscriptionId(this, preferredSim)) {
|
||||
simSubscriptionId = preferredSim;
|
||||
Log.d(TAG, "Using app-preferred SIM subscription ID: " + preferredSim);
|
||||
} else {
|
||||
Log.w(TAG, "App-preferred SIM subscription ID " + preferredSim + " is no longer valid, using device default");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if SMS payload contains valid recipients
|
||||
String[] recipients = smsPayload.getRecipients();
|
||||
@@ -82,9 +159,10 @@ public class FCMService extends FirebaseMessagingService {
|
||||
for (String recipient : recipients) {
|
||||
boolean smsSent;
|
||||
|
||||
// Try to send using default or specific SIM based on preference
|
||||
if (preferredSim == -1) {
|
||||
// Send using determined SIM (or device default if simSubscriptionId is null)
|
||||
if (simSubscriptionId == null) {
|
||||
// Use default SIM
|
||||
Log.d(TAG, "Using device default SIM");
|
||||
smsSent = SMSHelper.sendSMS(
|
||||
recipient,
|
||||
smsPayload.getMessage(),
|
||||
@@ -98,7 +176,7 @@ public class FCMService extends FirebaseMessagingService {
|
||||
smsSent = SMSHelper.sendSMSFromSpecificSim(
|
||||
recipient,
|
||||
smsPayload.getMessage(),
|
||||
preferredSim,
|
||||
simSubscriptionId,
|
||||
smsPayload.getSmsId(),
|
||||
smsPayload.getSmsBatchId(),
|
||||
this
|
||||
|
||||
@@ -4,6 +4,8 @@ import com.vernu.sms.dtos.SMSDTO;
|
||||
import com.vernu.sms.dtos.SMSForwardResponseDTO;
|
||||
import com.vernu.sms.dtos.RegisterDeviceInputDTO;
|
||||
import com.vernu.sms.dtos.RegisterDeviceResponseDTO;
|
||||
import com.vernu.sms.dtos.HeartbeatInputDTO;
|
||||
import com.vernu.sms.dtos.HeartbeatResponseDTO;
|
||||
|
||||
import retrofit2.Call;
|
||||
import retrofit2.http.Body;
|
||||
@@ -24,4 +26,7 @@ public interface GatewayApiService {
|
||||
|
||||
@PATCH("gateway/devices/{deviceId}/sms-status")
|
||||
Call<SMSForwardResponseDTO> updateSMSStatus(@Path("deviceId") String deviceId, @Header("x-api-key") String apiKey, @Body() SMSDTO body);
|
||||
|
||||
@POST("gateway/devices/{deviceId}/heartbeat")
|
||||
Call<HeartbeatResponseDTO> heartbeat(@Path("deviceId") String deviceId, @Header("x-api-key") String apiKey, @Body() HeartbeatInputDTO body);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.vernu.sms.workers;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.work.Worker;
|
||||
import androidx.work.WorkerParameters;
|
||||
|
||||
import com.vernu.sms.AppConstants;
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper;
|
||||
import com.vernu.sms.helpers.HeartbeatHelper;
|
||||
|
||||
public class HeartbeatWorker extends Worker {
|
||||
private static final String TAG = "HeartbeatWorker";
|
||||
|
||||
public HeartbeatWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) {
|
||||
super(context, workerParams);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Result doWork() {
|
||||
Context context = getApplicationContext();
|
||||
|
||||
// Check if device is eligible for heartbeat
|
||||
if (!HeartbeatHelper.isDeviceEligibleForHeartbeat(context)) {
|
||||
Log.d(TAG, "Device not eligible for heartbeat, skipping");
|
||||
return Result.success(); // Not a failure, just skip
|
||||
}
|
||||
|
||||
// Get device ID and API key
|
||||
String deviceId = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context,
|
||||
AppConstants.SHARED_PREFS_DEVICE_ID_KEY,
|
||||
""
|
||||
);
|
||||
|
||||
String apiKey = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context,
|
||||
AppConstants.SHARED_PREFS_API_KEY_KEY,
|
||||
""
|
||||
);
|
||||
|
||||
// Send heartbeat using shared helper
|
||||
boolean success = HeartbeatHelper.sendHeartbeat(context, deviceId, apiKey);
|
||||
|
||||
if (success) {
|
||||
return Result.success();
|
||||
} else {
|
||||
Log.e(TAG, "Failed to send heartbeat, will retry");
|
||||
return Result.retry();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,13 +94,22 @@ public class SMSReceivedWorker extends Worker {
|
||||
.addTag("sms_received")
|
||||
.build();
|
||||
|
||||
String uniqueWorkName = "sms_received_" + System.currentTimeMillis();
|
||||
// Use fingerprint for unique work name if available, otherwise fallback to timestamp
|
||||
String uniqueWorkName;
|
||||
if (smsDTO.getFingerprint() != null && !smsDTO.getFingerprint().isEmpty()) {
|
||||
uniqueWorkName = "sms_received_" + smsDTO.getFingerprint();
|
||||
} else {
|
||||
// Fallback to timestamp if fingerprint is not available
|
||||
uniqueWorkName = "sms_received_" + System.currentTimeMillis();
|
||||
Log.w(TAG, "Fingerprint not available, using timestamp for work name");
|
||||
}
|
||||
|
||||
WorkManager.getInstance(context)
|
||||
.beginUniqueWork(uniqueWorkName,
|
||||
androidx.work.ExistingWorkPolicy.APPEND_OR_REPLACE,
|
||||
androidx.work.ExistingWorkPolicy.KEEP,
|
||||
workRequest)
|
||||
.enqueue();
|
||||
|
||||
Log.d(TAG, "Work enqueued for received SMS from: " + smsDTO.getSender());
|
||||
Log.d(TAG, "Work enqueued for received SMS from: " + smsDTO.getSender() + " with fingerprint: " + uniqueWorkName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:color="?attr/colorPrimary" android:state_enabled="true"/>
|
||||
<item android:color="?attr/colorPrimary" android:alpha="0.12"/>
|
||||
</selector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:color="@color/white" android:state_enabled="true"/>
|
||||
<item android:color="@color/white" android:alpha="0.38"/>
|
||||
</selector>
|
||||
@@ -20,26 +20,52 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/colorPrimary"
|
||||
android:orientation="vertical"
|
||||
android:padding="24dp">
|
||||
android:padding="28dp">
|
||||
|
||||
<TextView
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="textbee.dev - sms gateway"
|
||||
android:textAlignment="center"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold" />
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:layout_marginBottom="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:text="Your ultimate solution for seamless SMS communication"
|
||||
android:textAlignment="center"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="12sp" />
|
||||
<ImageView
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:src="@mipmap/ic_launcher"
|
||||
android:contentDescription="App Icon" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="textbee.dev - sms gateway"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold"
|
||||
android:letterSpacing="0.02"
|
||||
android:shadowColor="#80000000"
|
||||
android:shadowDx="0"
|
||||
android:shadowDy="2"
|
||||
android:shadowRadius="4" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="Streamline your SMS workflows"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="13sp"
|
||||
android:letterSpacing="0.01"
|
||||
android:alpha="0.95" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Device Info Card -->
|
||||
@@ -50,21 +76,21 @@
|
||||
android:layout_marginTop="-24dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
app:cardBackgroundColor="@color/background_secondary"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:cardElevation="4dp">
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="6dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal"
|
||||
android:padding="6dp">
|
||||
android:padding="12dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_width="52dp"
|
||||
android:layout_height="52dp"
|
||||
android:layout_gravity="center"
|
||||
android:padding="3dp"
|
||||
android:padding="6dp"
|
||||
android:src="@drawable/ic_baseline_phone_android_24"
|
||||
android:tint="?attr/colorPrimary" />
|
||||
|
||||
@@ -72,7 +98,7 @@
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:layout_marginStart="3dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
@@ -109,11 +135,11 @@
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/copyDeviceIdImgBtn"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_marginStart="2dp"
|
||||
android:layout_width="26dp"
|
||||
android:layout_height="26dp"
|
||||
android:layout_marginStart="4dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:padding="2dp"
|
||||
android:padding="4dp"
|
||||
android:src="@drawable/ic_baseline_content_copy_24"
|
||||
android:tint="?attr/colorPrimary" />
|
||||
</LinearLayout>
|
||||
@@ -163,23 +189,24 @@
|
||||
android:layout_marginHorizontal="16dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
app:cardBackgroundColor="@color/background_secondary"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:cardElevation="2dp">
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="4dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
android:padding="20dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:layout_marginBottom="18dp"
|
||||
android:text="Account Information"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
android:textSize="19sp"
|
||||
android:textStyle="bold"
|
||||
android:letterSpacing="0.01" />
|
||||
|
||||
<!-- Device ID Input Field -->
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
@@ -212,12 +239,33 @@
|
||||
android:hint="API Key"
|
||||
app:boxBackgroundColor="@android:color/transparent"
|
||||
app:boxStrokeColor="?attr/colorPrimary"
|
||||
app:hintTextColor="?attr/colorPrimary">
|
||||
app:hintTextColor="?attr/colorPrimary"
|
||||
app:endIconMode="password_toggle"
|
||||
app:endIconTint="?attr/colorPrimary">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/apiKeyEditText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textPassword"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textIsSelectable="true" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:hint="Device name (optional)"
|
||||
app:boxBackgroundColor="@android:color/transparent"
|
||||
app:boxStrokeColor="?attr/colorPrimary"
|
||||
app:hintTextColor="?attr/colorPrimary"
|
||||
app:endIconTint="?attr/colorPrimary">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/deviceNameEditText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textIsSelectable="true" />
|
||||
@@ -246,10 +294,15 @@
|
||||
android:id="@+id/registerDeviceBtn"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:backgroundTint="?attr/colorPrimary"
|
||||
android:paddingHorizontal="24dp"
|
||||
android:backgroundTint="@color/button_background_tint"
|
||||
android:paddingHorizontal="28dp"
|
||||
android:paddingVertical="12dp"
|
||||
android:text="Connect"
|
||||
android:textColor="@color/white" />
|
||||
android:textColor="@color/button_text_color"
|
||||
android:textSize="15sp"
|
||||
android:letterSpacing="0.01"
|
||||
style="@style/Widget.MaterialComponents.Button"
|
||||
app:cornerRadius="8dp" />
|
||||
|
||||
<View
|
||||
android:layout_width="0dp"
|
||||
@@ -259,11 +312,20 @@
|
||||
android:id="@+id/scanQRButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="5dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:drawableLeft="@drawable/ic_baseline_qr_code_24"
|
||||
android:drawablePadding="6dp"
|
||||
android:paddingHorizontal="20dp"
|
||||
android:paddingVertical="12dp"
|
||||
android:text="Scan QR"
|
||||
android:textColor="@color/black"
|
||||
android:theme="@style/Theme.Design.Light" />
|
||||
android:textColor="?attr/colorPrimary"
|
||||
android:textSize="15sp"
|
||||
android:letterSpacing="0.01"
|
||||
android:backgroundTint="@android:color/transparent"
|
||||
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
|
||||
app:cornerRadius="8dp"
|
||||
app:strokeColor="?attr/colorPrimary"
|
||||
app:strokeWidth="1.5dp" />
|
||||
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
@@ -276,23 +338,24 @@
|
||||
android:layout_marginHorizontal="16dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
app:cardBackgroundColor="@color/background_secondary"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:cardElevation="2dp">
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="4dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
android:padding="20dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:layout_marginBottom="18dp"
|
||||
android:text="Configuration"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
android:textSize="19sp"
|
||||
android:textStyle="bold"
|
||||
android:letterSpacing="0.01" />
|
||||
|
||||
<!-- Permissions Section -->
|
||||
<LinearLayout
|
||||
@@ -309,30 +372,28 @@
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="REQUIRED for textbee to function!"
|
||||
android:textColor="@android:color/holo_red_dark"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/grantSMSPermissionBtn"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:backgroundTint="?attr/colorPrimary"
|
||||
android:backgroundTint="@color/button_background_tint"
|
||||
android:paddingHorizontal="24dp"
|
||||
android:paddingVertical="12dp"
|
||||
android:text="Grant SMS Permissions"
|
||||
android:textColor="@color/white"
|
||||
android:visibility="visible" />
|
||||
android:textColor="@color/button_text_color"
|
||||
android:textSize="15sp"
|
||||
android:letterSpacing="0.01"
|
||||
android:visibility="visible"
|
||||
style="@style/Widget.MaterialComponents.Button"
|
||||
app:cornerRadius="8dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:layout_height="1.5dp"
|
||||
android:background="@color/divider"
|
||||
android:layout_marginBottom="16dp" />
|
||||
android:alpha="0.6"
|
||||
android:layout_marginBottom="18dp" />
|
||||
|
||||
<!-- Receive SMS Toggle -->
|
||||
<LinearLayout
|
||||
@@ -371,6 +432,22 @@
|
||||
android:minHeight="32dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Filter Configuration Link -->
|
||||
<Button
|
||||
android:id="@+id/configureFilterBtn"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:text="Configure Filter"
|
||||
android:textColor="?attr/colorPrimary"
|
||||
android:textSize="15sp"
|
||||
android:letterSpacing="0.01"
|
||||
android:backgroundTint="@android:color/transparent"
|
||||
android:drawableStart="@android:drawable/ic_menu_manage"
|
||||
android:drawablePadding="6dp"
|
||||
android:paddingHorizontal="0dp"
|
||||
style="@style/Widget.MaterialComponents.Button.TextButton" />
|
||||
|
||||
<!-- Sticky Notification Setting -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
@@ -400,11 +477,11 @@
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="16dp"
|
||||
android:layout_height="16dp"
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:src="@android:drawable/ic_dialog_info"
|
||||
android:tint="?attr/colorPrimary"
|
||||
android:layout_marginEnd="4dp" />
|
||||
android:layout_marginEnd="6dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
@@ -424,9 +501,10 @@
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:layout_height="1.5dp"
|
||||
android:background="@color/divider"
|
||||
android:layout_marginBottom="16dp" />
|
||||
android:alpha="0.6"
|
||||
android:layout_marginBottom="18dp" />
|
||||
|
||||
<!-- Default SIM Selection -->
|
||||
<TextView
|
||||
@@ -440,10 +518,11 @@
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:text="Select your preferred SIM for sending SMS"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:text="This is the default SIM that will be used for sending SMS. You can override for individual SMS by providing the specific simSubscriptionId in your API request."
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="14sp" />
|
||||
android:textSize="14sp"
|
||||
android:lineSpacingMultiplier="1.2" />
|
||||
|
||||
<RadioGroup
|
||||
android:id="@+id/defaultSimSlotRadioGroup"
|
||||
@@ -462,38 +541,41 @@
|
||||
android:layout_marginHorizontal="16dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
app:cardBackgroundColor="@color/background_secondary"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:cardElevation="2dp">
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="4dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
android:padding="20dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:text="How To Use"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
android:textSize="19sp"
|
||||
android:textStyle="bold"
|
||||
android:letterSpacing="0.01" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="8dp">
|
||||
android:layout_marginBottom="10dp"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_width="28dp"
|
||||
android:layout_height="28dp"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:background="?attr/colorPrimary"
|
||||
android:gravity="center"
|
||||
android:text="1"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
@@ -512,16 +594,18 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="8dp">
|
||||
android:layout_marginBottom="10dp"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_width="28dp"
|
||||
android:layout_height="28dp"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:background="?attr/colorPrimary"
|
||||
android:gravity="center"
|
||||
android:text="2"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
@@ -534,16 +618,18 @@
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_width="28dp"
|
||||
android:layout_height="28dp"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:background="?attr/colorPrimary"
|
||||
android:gravity="center"
|
||||
android:text="3"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
@@ -562,14 +648,14 @@
|
||||
android:layout_marginHorizontal="16dp"
|
||||
android:layout_marginBottom="24dp"
|
||||
app:cardBackgroundColor="@color/background_secondary"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:cardElevation="2dp">
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="4dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
android:padding="20dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
@@ -578,11 +664,12 @@
|
||||
android:gravity="center_horizontal">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_width="36dp"
|
||||
android:layout_height="36dp"
|
||||
android:src="@drawable/ic_baseline_info_24"
|
||||
android:tint="?attr/colorPrimary"
|
||||
android:layout_marginEnd="12dp" />
|
||||
android:padding="4dp"
|
||||
android:layout_marginEnd="14dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/background_primary"
|
||||
tools:context=".activities.SMSFilterActivity">
|
||||
|
||||
<!-- Header Section -->
|
||||
<LinearLayout
|
||||
android:id="@+id/header"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/colorPrimary"
|
||||
android:orientation="vertical"
|
||||
android:padding="28dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="10dp">
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/backButton"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:src="@android:drawable/ic_menu_revert"
|
||||
android:tint="@color/white"
|
||||
android:contentDescription="Back"
|
||||
android:padding="8dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="SMS Filter"
|
||||
android:textAlignment="center"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold"
|
||||
android:letterSpacing="0.02"
|
||||
android:shadowColor="#80000000"
|
||||
android:shadowDx="0"
|
||||
android:shadowDy="2"
|
||||
android:shadowRadius="4" />
|
||||
|
||||
<View
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Configure SMS filtering rules"
|
||||
android:textAlignment="center"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="13sp"
|
||||
android:letterSpacing="0.01"
|
||||
android:alpha="0.95" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Scrollable Content -->
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_below="@id/header">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<!-- Filter Settings Card -->
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="16dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
app:cardBackgroundColor="@color/background_secondary"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="4dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="20dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="18dp"
|
||||
android:text="Filter Settings"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="19sp"
|
||||
android:textStyle="bold"
|
||||
android:letterSpacing="0.01" />
|
||||
|
||||
<!-- Filter Enabled Toggle -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Enable Filter"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Enable SMS filtering"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<Switch
|
||||
android:id="@+id/filterEnabledSwitch"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:minHeight="32dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1.5dp"
|
||||
android:background="@color/divider"
|
||||
android:alpha="0.6"
|
||||
android:layout_marginBottom="18dp" />
|
||||
|
||||
<!-- Filter Mode Selection -->
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:text="Filter Mode"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<RadioGroup
|
||||
android:id="@+id/filterModeRadioGroup"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:layout_marginStart="8dp">
|
||||
|
||||
<RadioButton
|
||||
android:id="@+id/allowListRadio"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Allow List (only process SMS matching these filters)"
|
||||
android:textColor="@color/text_primary"
|
||||
android:paddingVertical="8dp" />
|
||||
|
||||
<RadioButton
|
||||
android:id="@+id/blockListRadio"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Block List (exclude these numbers/messages)"
|
||||
android:textColor="@color/text_primary"
|
||||
android:paddingVertical="8dp" />
|
||||
</RadioGroup>
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<!-- Filter Rules Card -->
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="16dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
app:cardBackgroundColor="@color/background_secondary"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="4dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="20dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="18dp"
|
||||
android:text="Filter Rules"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="19sp"
|
||||
android:textStyle="bold"
|
||||
android:letterSpacing="0.01" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/filterRulesRecyclerView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:nestedScrollingEnabled="false" />
|
||||
|
||||
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
android:id="@+id/addRuleFab"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="end"
|
||||
android:layout_marginTop="16dp"
|
||||
android:src="@android:drawable/ic_input_add"
|
||||
app:tint="@color/white"
|
||||
app:backgroundTint="?attr/colorPrimary" />
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
</RelativeLayout>
|
||||
@@ -0,0 +1,132 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="24dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/dialogTitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="20dp"
|
||||
android:text="Add Filter Rule"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:hint="Pattern (phone number or text)"
|
||||
app:boxBackgroundColor="@android:color/transparent"
|
||||
app:boxStrokeColor="?attr/colorPrimary"
|
||||
app:hintTextColor="?attr/colorPrimary">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/patternEditText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text"
|
||||
android:textColor="@color/text_primary"
|
||||
android:maxLines="1" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:text="Filter Target"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/filterTargetSpinner"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:minHeight="48dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:text="Match Type"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/matchTypeSpinner"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:minHeight="48dp" />
|
||||
|
||||
<!-- Case Sensitivity Toggle -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="24dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Case Sensitive"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Match pattern with exact case"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<Switch
|
||||
android:id="@+id/caseSensitiveSwitch"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:minHeight="32dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="end">
|
||||
|
||||
<Button
|
||||
android:id="@+id/cancelButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:text="Cancel"
|
||||
android:textColor="?attr/colorPrimary"
|
||||
android:backgroundTint="@android:color/transparent"
|
||||
style="@style/Widget.MaterialComponents.Button.TextButton" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/saveButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Save"
|
||||
android:textColor="@color/button_text_color"
|
||||
android:backgroundTint="@color/button_background_tint"
|
||||
style="@style/Widget.MaterialComponents.Button"
|
||||
app:cornerRadius="8dp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,71 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
app:cardBackgroundColor="@color/background_primary"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:cardElevation="2dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/patternText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Pattern"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/matchTypeText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="Match Type"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/filterTargetText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:text="Filter Target"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/editButton"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:src="@android:drawable/ic_menu_edit"
|
||||
android:tint="?attr/colorPrimary"
|
||||
android:contentDescription="Edit rule" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/deleteButton"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:src="@android:drawable/ic_menu_delete"
|
||||
android:tint="#F44336"
|
||||
android:contentDescription="Delete rule" />
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
@@ -2,12 +2,12 @@
|
||||
<!-- Base application theme. -->
|
||||
<style name="Theme.SMSGateway" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
||||
<!-- Primary brand color. -->
|
||||
<item name="colorPrimary">#D97706</item>
|
||||
<item name="colorPrimaryVariant">#B86504</item>
|
||||
<item name="colorPrimary">#C4620A</item>
|
||||
<item name="colorPrimaryVariant">#A04405</item>
|
||||
<item name="colorOnPrimary">@color/white</item>
|
||||
<!-- Secondary brand color. -->
|
||||
<item name="colorSecondary">#f35b04</item>
|
||||
<item name="colorSecondaryVariant">#f18701</item>
|
||||
<item name="colorSecondary">#B45309</item>
|
||||
<item name="colorSecondaryVariant">#92400E</item>
|
||||
<item name="colorOnSecondary">@color/black</item>
|
||||
<!-- Status bar color. -->
|
||||
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
|
||||
@@ -23,12 +23,12 @@
|
||||
<!-- Theme without action bar for MainActivity -->
|
||||
<style name="Theme.SMSGateway.NoActionBar" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
||||
<!-- Primary brand color. -->
|
||||
<item name="colorPrimary">#D97706</item>
|
||||
<item name="colorPrimaryVariant">#B86504</item>
|
||||
<item name="colorPrimary">#C4620A</item>
|
||||
<item name="colorPrimaryVariant">#A04405</item>
|
||||
<item name="colorOnPrimary">@color/white</item>
|
||||
<!-- Secondary brand color. -->
|
||||
<item name="colorSecondary">#f35b04</item>
|
||||
<item name="colorSecondaryVariant">#f18701</item>
|
||||
<item name="colorSecondary">#B45309</item>
|
||||
<item name="colorSecondaryVariant">#92400E</item>
|
||||
<item name="colorOnSecondary">@color/black</item>
|
||||
<!-- Status bar color. -->
|
||||
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
<resources>
|
||||
<string name="app_name">textbee.dev</string>
|
||||
</resources>
|
||||
@@ -2,12 +2,12 @@
|
||||
<!-- Base application theme. -->
|
||||
<style name="Theme.SMSGateway" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
||||
<!-- Primary brand color. -->
|
||||
<item name="colorPrimary">#D97706</item>
|
||||
<item name="colorPrimaryVariant">#B86504</item>
|
||||
<item name="colorPrimary">#C4620A</item>
|
||||
<item name="colorPrimaryVariant">#A04405</item>
|
||||
<item name="colorOnPrimary">@color/white</item>
|
||||
<!-- Secondary brand color. -->
|
||||
<item name="colorSecondary">#f35b04</item>
|
||||
<item name="colorSecondaryVariant">#f18701</item>
|
||||
<item name="colorSecondary">#B45309</item>
|
||||
<item name="colorSecondaryVariant">#92400E</item>
|
||||
<item name="colorOnSecondary">@color/black</item>
|
||||
<!-- Status bar color. -->
|
||||
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
|
||||
@@ -17,12 +17,12 @@
|
||||
<!-- Theme without action bar for MainActivity -->
|
||||
<style name="Theme.SMSGateway.NoActionBar" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
||||
<!-- Primary brand color. -->
|
||||
<item name="colorPrimary">#D97706</item>
|
||||
<item name="colorPrimaryVariant">#B86504</item>
|
||||
<item name="colorPrimary">#C4620A</item>
|
||||
<item name="colorPrimaryVariant">#A04405</item>
|
||||
<item name="colorOnPrimary">@color/white</item>
|
||||
<!-- Secondary brand color. -->
|
||||
<item name="colorSecondary">#f35b04</item>
|
||||
<item name="colorSecondaryVariant">#f18701</item>
|
||||
<item name="colorSecondary">#B45309</item>
|
||||
<item name="colorSecondaryVariant">#92400E</item>
|
||||
<item name="colorOnSecondary">@color/black</item>
|
||||
<!-- Status bar color. -->
|
||||
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "YOUR_DEV_PROJECT_NUMBER",
|
||||
"project_id": "your-dev-project-id",
|
||||
"storage_bucket": "your-dev-project-id.appspot.com"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:YOUR_DEV_PROJECT_NUMBER:android:YOUR_DEV_APP_ID",
|
||||
"android_client_info": {
|
||||
"package_name": "com.vernu.sms.dev"
|
||||
}
|
||||
},
|
||||
"oauth_client": [
|
||||
{
|
||||
"client_id": "YOUR_DEV_CLIENT_ID",
|
||||
"client_type": 3
|
||||
}
|
||||
],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "YOUR_DEV_API_KEY"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": [
|
||||
{
|
||||
"client_id": "YOUR_DEV_CLIENT_ID",
|
||||
"client_type": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
@@ -28,3 +28,5 @@ MAIL_REPLY_TO=
|
||||
# SMS Queue Configuration
|
||||
USE_SMS_QUEUE=false
|
||||
REDIS_URL=redis://localhost:6379 # if queue is enabled, redis url is required
|
||||
|
||||
CLOUDFLARE_TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
|
||||
+44
-43
@@ -11,7 +11,7 @@
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start": "node dist/main",
|
||||
"start": "node dist/main.js",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
@@ -21,59 +21,60 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@nest-modules/mailer": "^1.3.22",
|
||||
"@nestjs/bull": "^11.0.2",
|
||||
"@nestjs/common": "^10.4.5",
|
||||
"@nestjs/config": "^4.0.1",
|
||||
"@nestjs/core": "^10.4.5",
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/bull": "^11.0.4",
|
||||
"@nestjs/common": "^11.1.9",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.1.9",
|
||||
"@nestjs/event-emitter": "^3.0.1",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/mapped-types": "^2.1.0",
|
||||
"@nestjs/mongoose": "^10.0.10",
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-express": "^10.4.5",
|
||||
"@nestjs/schedule": "^4.1.1",
|
||||
"@nestjs/swagger": "^7.4.2",
|
||||
"@nestjs/throttler": "^6.2.1",
|
||||
"@polar-sh/sdk": "^0.32.3",
|
||||
"axios": "^1.8.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"@nestjs/mongoose": "^11.0.4",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.1.9",
|
||||
"@nestjs/schedule": "^6.1.0",
|
||||
"@nestjs/swagger": "^11.2.3",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@polar-sh/sdk": "^0.41.5",
|
||||
"axios": "^1.13.2",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"bull": "^4.16.5",
|
||||
"class-validator": "^0.14.1",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.21.2",
|
||||
"firebase-admin": "^12.6.0",
|
||||
"class-validator": "^0.14.3",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^5.2.1",
|
||||
"firebase-admin": "^13.6.0",
|
||||
"handlebars": "^4.7.8",
|
||||
"ioredis": "^5.6.0",
|
||||
"mongoose": "^8.12.1",
|
||||
"nodemailer": "^6.10.0",
|
||||
"ioredis": "^5.8.2",
|
||||
"mongoose": "^9.0.1",
|
||||
"nodemailer": "^7.0.11",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rimraf": "^6.0.1",
|
||||
"rxjs": "^7.8.1",
|
||||
"rimraf": "^6.1.2",
|
||||
"rxjs": "^7.8.2",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"uuid": "^10.0.0"
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.5",
|
||||
"@nestjs/schematics": "^10.2.2",
|
||||
"@nestjs/testing": "^10.4.5",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "29.5.13",
|
||||
"@types/node": "^22.7.7",
|
||||
"@types/nodemailer": "^6.4.16",
|
||||
"@nestjs/cli": "^11.0.14",
|
||||
"@nestjs/schematics": "^11.0.9",
|
||||
"@nestjs/testing": "^11.1.9",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/jest": "30.0.0",
|
||||
"@types/node": "^25.0.1",
|
||||
"@types/nodemailer": "^7.0.4",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.10.0",
|
||||
"@typescript-eslint/parser": "^8.10.0",
|
||||
"eslint": "^9.13.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"jest": "^29.7.0",
|
||||
"prettier": "^3.3.3",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.49.0",
|
||||
"@typescript-eslint/parser": "^8.49.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"jest": "^30.2.0",
|
||||
"prettier": "^3.7.4",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.1",
|
||||
"supertest": "^7.1.4",
|
||||
"ts-jest": "^29.4.6",
|
||||
"ts-loader": "^9.5.4",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.6.3"
|
||||
|
||||
Generated
+3709
-2853
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,7 @@ import { BillingModule } from './billing/billing.module'
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config'
|
||||
import { BullModule } from '@nestjs/bull'
|
||||
import { SupportModule } from './support/support.module'
|
||||
import { EventEmitterModule } from '@nestjs/event-emitter'
|
||||
|
||||
@Injectable()
|
||||
export class LoggerMiddleware implements NestMiddleware {
|
||||
@@ -36,6 +37,7 @@ export class LoggerMiddleware implements NestMiddleware {
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
}),
|
||||
EventEmitterModule.forRoot(),
|
||||
ThrottlerModule.forRoot([
|
||||
{
|
||||
ttl: 60000,
|
||||
|
||||
@@ -12,6 +12,9 @@ export class RegisterInputDTO {
|
||||
|
||||
@ApiProperty({ type: String, required: true })
|
||||
password: string
|
||||
|
||||
@ApiProperty({ type: String, required: true })
|
||||
turnstileToken: string
|
||||
}
|
||||
|
||||
export class LoginInputDTO {
|
||||
@@ -20,11 +23,17 @@ export class LoginInputDTO {
|
||||
|
||||
@ApiProperty({ type: String, required: true })
|
||||
password: string
|
||||
|
||||
@ApiProperty({ type: String, required: true })
|
||||
turnstileToken: string
|
||||
}
|
||||
|
||||
export class RequestResetPasswordInputDTO {
|
||||
@ApiProperty({ type: String, required: true })
|
||||
email: string
|
||||
|
||||
@ApiProperty({ type: String, required: true })
|
||||
turnstileToken: string
|
||||
}
|
||||
|
||||
export class ResetPasswordInputDTO {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AuthService } from './auth.service'
|
||||
import { JwtStrategy } from './jwt.strategy'
|
||||
import { ApiKey, ApiKeySchema } from './schemas/api-key.schema'
|
||||
import { MailModule } from 'src/mail/mail.module'
|
||||
import { CommonModule } from '../common/common.module'
|
||||
import {
|
||||
PasswordReset,
|
||||
PasswordResetSchema,
|
||||
@@ -41,12 +42,21 @@ import { OptionalAuthGuard } from './guards/optional-auth.guard'
|
||||
PassportModule,
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET,
|
||||
signOptions: { expiresIn: process.env.JWT_EXPIRATION || '60d' },
|
||||
signOptions: {
|
||||
expiresIn: process.env.JWT_EXPIRATION || '60d' as any
|
||||
},
|
||||
}),
|
||||
MailModule,
|
||||
CommonModule,
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy, AuthGuard, OptionalAuthGuard, MongooseModule],
|
||||
providers: [
|
||||
AuthService,
|
||||
JwtStrategy,
|
||||
AuthGuard,
|
||||
OptionalAuthGuard,
|
||||
MongooseModule,
|
||||
],
|
||||
exports: [AuthService, JwtModule, AuthGuard, OptionalAuthGuard],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
PasswordResetDocument,
|
||||
} from './schemas/password-reset.schema'
|
||||
import { MailService } from '../mail/mail.service'
|
||||
import { TurnstileService } from '../common/turnstile.service'
|
||||
import { RequestResetPasswordInputDTO, ResetPasswordInputDTO } from './auth.dto'
|
||||
import { AccessLog } from './schemas/access-log.schema'
|
||||
import {
|
||||
@@ -32,9 +33,12 @@ export class AuthService {
|
||||
@InjectModel(EmailVerification.name)
|
||||
private emailVerificationModel: Model<EmailVerificationDocument>,
|
||||
private readonly mailService: MailService,
|
||||
private readonly turnstileService: TurnstileService,
|
||||
) {}
|
||||
|
||||
async login(userData: any) {
|
||||
await this.turnstileService.verify(userData.turnstileToken)
|
||||
|
||||
const user = await this.usersService.findOne({ email: userData.email })
|
||||
if (!user) {
|
||||
throw new HttpException(
|
||||
@@ -109,6 +113,8 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async register(userData: any) {
|
||||
await this.turnstileService.verify(userData.turnstileToken)
|
||||
|
||||
const existingUser = await this.usersService.findOne({
|
||||
email: userData.email,
|
||||
})
|
||||
@@ -123,8 +129,9 @@ export class AuthService {
|
||||
this.validatePassword(userData.password)
|
||||
|
||||
const hashedPassword = await bcrypt.hash(userData.password, 10)
|
||||
const { turnstileToken, ...sanitizedUserData } = userData
|
||||
const user = await this.usersService.create({
|
||||
...userData,
|
||||
...sanitizedUserData,
|
||||
password: hashedPassword,
|
||||
})
|
||||
|
||||
@@ -152,7 +159,12 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
async requestResetPassword({ email }: RequestResetPasswordInputDTO) {
|
||||
async requestResetPassword({
|
||||
email,
|
||||
turnstileToken,
|
||||
}: RequestResetPasswordInputDTO) {
|
||||
await this.turnstileService.verify(turnstileToken)
|
||||
|
||||
const user = await this.usersService.findOne({ email })
|
||||
if (!user) {
|
||||
return {
|
||||
@@ -160,6 +172,20 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user has requested password reset more than 5 times in the last 24 hours
|
||||
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000)
|
||||
const resetCount = await this.passwordResetModel.countDocuments({
|
||||
user: user._id,
|
||||
createdAt: { $gte: twentyFourHoursAgo }
|
||||
})
|
||||
|
||||
if (resetCount >= 5) {
|
||||
throw new HttpException(
|
||||
{ error: 'Too many password reset requests. Please try again later.' },
|
||||
HttpStatus.TOO_MANY_REQUESTS
|
||||
)
|
||||
}
|
||||
|
||||
const otp = Math.floor(100000 + Math.random() * 900000).toString()
|
||||
const expiresAt = new Date(Date.now() + 20 * 60 * 1000)
|
||||
|
||||
@@ -169,7 +195,7 @@ export class AuthService {
|
||||
otp: hashedOtp,
|
||||
expiresAt,
|
||||
})
|
||||
passwordReset.save()
|
||||
await passwordReset.save()
|
||||
|
||||
const resetLink = `${process.env.FRONTEND_URL || 'https://textbee.dev'}/reset-password?email=${encodeURIComponent(user.email)}&otp=${otp}`
|
||||
|
||||
@@ -205,6 +231,9 @@ export class AuthService {
|
||||
user.password = hashedPassword
|
||||
await user.save()
|
||||
|
||||
passwordReset.expiresAt = new Date(Date.now())
|
||||
await passwordReset.save()
|
||||
|
||||
this.mailService.sendEmailFromTemplate({
|
||||
to: user.email,
|
||||
subject: 'textbee.dev - Password Reset',
|
||||
@@ -237,12 +266,28 @@ export class AuthService {
|
||||
)
|
||||
}
|
||||
|
||||
this.validatePassword(input.newPassword)
|
||||
|
||||
const hashedPassword = await bcrypt.hash(input.newPassword, 10)
|
||||
userToUpdate.password = hashedPassword
|
||||
await userToUpdate.save()
|
||||
}
|
||||
|
||||
async sendEmailVerificationEmail(user: UserDocument) {
|
||||
// Check if user has requested email verification more than 5 times in the last 24 hours
|
||||
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000)
|
||||
const verificationCount = await this.emailVerificationModel.countDocuments({
|
||||
user: user._id,
|
||||
createdAt: { $gte: twentyFourHoursAgo }
|
||||
})
|
||||
|
||||
if (verificationCount >= 5) {
|
||||
throw new HttpException(
|
||||
{ error: 'Too many email verification requests. Please try again later.' },
|
||||
HttpStatus.TOO_MANY_REQUESTS
|
||||
)
|
||||
}
|
||||
|
||||
const verificationCode = uuidv4()
|
||||
const expiresAt = new Date(Date.now() + 20 * 60 * 1000) // 20 minutes
|
||||
|
||||
@@ -422,9 +467,9 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
async validatePassword(password: string) {
|
||||
if (password.length < 6) {
|
||||
if (password.length < 6 || password.length > 128) {
|
||||
throw new HttpException(
|
||||
{ error: 'Password must be at least 6 characters' },
|
||||
{ error: 'Password must be between 6 and 128 characters' },
|
||||
HttpStatus.BAD_REQUEST,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,10 +10,10 @@ export class AccessLog {
|
||||
_id?: Types.ObjectId
|
||||
|
||||
@Prop({ type: Types.ObjectId, ref: ApiKey.name })
|
||||
apiKey: ApiKey
|
||||
apiKey: ApiKey | Types.ObjectId
|
||||
|
||||
@Prop({ type: Types.ObjectId, ref: User.name })
|
||||
user: User
|
||||
user: User | Types.ObjectId
|
||||
|
||||
@Prop({ type: String })
|
||||
url: string
|
||||
|
||||
@@ -18,7 +18,7 @@ export class ApiKey {
|
||||
hashedApiKey: string
|
||||
|
||||
@Prop({ type: Types.ObjectId, ref: User.name })
|
||||
user: User
|
||||
user: User | Types.ObjectId
|
||||
|
||||
@Prop({ type: Number, default: 0 })
|
||||
usageCount: number
|
||||
|
||||
@@ -9,7 +9,7 @@ export class EmailVerification {
|
||||
_id?: Types.ObjectId
|
||||
|
||||
@Prop({ type: Types.ObjectId, ref: User.name })
|
||||
user: User
|
||||
user: User | Types.ObjectId
|
||||
|
||||
@Prop({ type: String })
|
||||
verificationCode: string // hashed
|
||||
|
||||
@@ -9,7 +9,7 @@ export class PasswordReset {
|
||||
_id?: Types.ObjectId
|
||||
|
||||
@Prop({ type: Types.ObjectId, ref: User.name })
|
||||
user: User
|
||||
user: User | Types.ObjectId
|
||||
|
||||
@Prop({ type: String })
|
||||
otp: string
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import { Injectable, Logger } from '@nestjs/common'
|
||||
import { Cron, CronExpression } from '@nestjs/schedule'
|
||||
import { InjectModel } from '@nestjs/mongoose'
|
||||
import { Model } from 'mongoose'
|
||||
import {
|
||||
CheckoutSession,
|
||||
CheckoutSessionDocument,
|
||||
} from './schemas/checkout-session.schema'
|
||||
import { User, UserDocument } from '../users/schemas/user.schema'
|
||||
import { MailService } from '../mail/mail.service'
|
||||
import { BillingService } from './billing.service'
|
||||
import { Plan } from './schemas/plan.schema'
|
||||
|
||||
interface EmailConfig {
|
||||
template: string
|
||||
subject: string
|
||||
minutesBeforeExpiry?: number // Send X minutes before expiry
|
||||
minutesAfterExpiry?: number // Send X minutes after expiry
|
||||
emailType:
|
||||
| 'first_reminder'
|
||||
| 'second_reminder'
|
||||
| 'third_reminder'
|
||||
| 'final_reminder'
|
||||
| 'last_chance'
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AbandonedCheckoutService {
|
||||
private readonly logger = new Logger(AbandonedCheckoutService.name)
|
||||
|
||||
private readonly emailSchedule: EmailConfig[] = [
|
||||
{
|
||||
template: 'abandoned-checkout-10-minutes',
|
||||
subject: '⏰ Your textbee pro upgrade is waiting!',
|
||||
minutesBeforeExpiry: 15,
|
||||
emailType: 'first_reminder',
|
||||
},
|
||||
|
||||
]
|
||||
|
||||
constructor(
|
||||
@InjectModel(CheckoutSession.name)
|
||||
private checkoutSessionModel: Model<CheckoutSessionDocument>,
|
||||
@InjectModel(User.name)
|
||||
private userModel: Model<UserDocument>,
|
||||
private mailService: MailService,
|
||||
private billingService: BillingService,
|
||||
) {}
|
||||
|
||||
@Cron(CronExpression.EVERY_10_MINUTES)
|
||||
async processAbandonedCheckouts() {
|
||||
this.logger.log('Starting abandoned checkout processing...')
|
||||
|
||||
try {
|
||||
for (const emailConfig of this.emailSchedule) {
|
||||
await this.sendReminderEmails(emailConfig)
|
||||
}
|
||||
this.logger.log('Abandoned checkout processing completed successfully')
|
||||
} catch (error) {
|
||||
this.logger.error('Error processing abandoned checkouts:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send reminder emails for a specific email configuration
|
||||
*/
|
||||
private async sendReminderEmails(emailConfig: EmailConfig) {
|
||||
const now = new Date()
|
||||
let windowStart: Date, windowEnd: Date
|
||||
let query: any
|
||||
|
||||
if (emailConfig.minutesBeforeExpiry !== undefined) {
|
||||
// BEFORE EXPIRY: Find sessions that will expire in X minutes
|
||||
const targetExpiryTime = new Date(now.getTime() + emailConfig.minutesBeforeExpiry * 60 * 1000)
|
||||
|
||||
windowStart = new Date(targetExpiryTime.getTime() - 10 * 60 * 1000)
|
||||
windowEnd = new Date(targetExpiryTime.getTime() + 10 * 60 * 1000)
|
||||
|
||||
query = {
|
||||
expiresAt: {
|
||||
$gte: windowStart,
|
||||
$lte: windowEnd,
|
||||
},
|
||||
'abandonedEmails.emailType': { $ne: emailConfig.emailType }, // Don't send duplicate emails
|
||||
}
|
||||
} else if (emailConfig.minutesAfterExpiry !== undefined) {
|
||||
// AFTER EXPIRY: Find sessions that expired X minutes ago
|
||||
const targetExpiryTime = new Date(now.getTime() - emailConfig.minutesAfterExpiry * 60 * 1000)
|
||||
|
||||
windowStart = new Date(targetExpiryTime.getTime() - 10 * 60 * 1000)
|
||||
windowEnd = new Date(targetExpiryTime.getTime() + 10 * 60 * 1000)
|
||||
|
||||
query = {
|
||||
expiresAt: {
|
||||
$gte: windowStart,
|
||||
$lte: windowEnd,
|
||||
},
|
||||
'abandonedEmails.emailType': { $ne: emailConfig.emailType }, // Don't send duplicate emails
|
||||
}
|
||||
} else {
|
||||
this.logger.error(`Invalid email config: must specify either minutesBeforeExpiry or minutesAfterExpiry`)
|
||||
return
|
||||
}
|
||||
|
||||
const abandonedSessions = await this.checkoutSessionModel
|
||||
.find(query)
|
||||
.populate('user')
|
||||
.limit(100)
|
||||
|
||||
if (abandonedSessions.length === 0) {
|
||||
this.logger.debug(
|
||||
`No abandoned checkouts found for ${emailConfig.emailType}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Found ${abandonedSessions.length} abandoned checkouts for ${emailConfig.emailType}`,
|
||||
)
|
||||
|
||||
for (const session of abandonedSessions) {
|
||||
try {
|
||||
// check if user is already on pro plan
|
||||
const subscription = await this.billingService.getCurrentSubscription(
|
||||
session.user,
|
||||
)
|
||||
if ((subscription?.plan as Plan)?.name !== 'free') {
|
||||
this.logger.debug(
|
||||
`Skipping email for session ${session._id}: user is not on free plan`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
await this.sendAbandonedCheckoutEmail(session, emailConfig)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to send ${emailConfig.emailType} email for session ${session._id}:`,
|
||||
error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an individual abandoned checkout email
|
||||
*/
|
||||
private async sendAbandonedCheckoutEmail(
|
||||
session: CheckoutSessionDocument,
|
||||
emailConfig: EmailConfig,
|
||||
) {
|
||||
const user = session.user as UserDocument
|
||||
|
||||
// Skip if user has opted out of marketing emails
|
||||
// if (user.marketingEmailsOptOut) {
|
||||
// this.logger.debug(`Skipping email for session ${session._id}: user opted out of marketing emails`)
|
||||
// return
|
||||
// }
|
||||
|
||||
try {
|
||||
const emailContext = {
|
||||
name: user.name?.split(' ')?.[0] || 'there',
|
||||
email: user.email,
|
||||
checkoutUrl:
|
||||
'https://app.textbee.dev/checkout/pro' /*session.checkoutUrl*/,
|
||||
planName: this.extractPlanNameFromPayload(session.payload),
|
||||
expiresAt: session.expiresAt,
|
||||
}
|
||||
|
||||
await this.mailService.sendEmailFromTemplate({
|
||||
to: user.email,
|
||||
from: 'support@textbee.dev',
|
||||
subject: emailConfig.subject,
|
||||
template: emailConfig.template,
|
||||
context: emailContext,
|
||||
})
|
||||
|
||||
await this.checkoutSessionModel.updateOne(
|
||||
{ _id: session._id },
|
||||
{
|
||||
$push: {
|
||||
abandonedEmails: {
|
||||
emailType: emailConfig.emailType,
|
||||
sentAt: new Date(),
|
||||
emailSubject: emailConfig.subject,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
this.logger.log(
|
||||
`Sent ${emailConfig.emailType} email to ${user.email} for session ${session._id}`,
|
||||
)
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to send abandoned checkout email:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private extractPlanNameFromPayload(payload: any): string {
|
||||
if (payload?.products?.[0]?.name) {
|
||||
return payload.products[0].name
|
||||
}
|
||||
if (payload?.product?.name) {
|
||||
return payload.product.name
|
||||
}
|
||||
if (payload?.planName) {
|
||||
return payload.planName
|
||||
}
|
||||
return 'Pro' // Default fallback
|
||||
}
|
||||
|
||||
async markCheckoutCompleted(checkoutSessionId: string) {
|
||||
try {
|
||||
const result = await this.checkoutSessionModel.updateOne(
|
||||
{ checkoutSessionId },
|
||||
{
|
||||
isCompleted: true,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
)
|
||||
|
||||
if (result.matchedCount > 0) {
|
||||
this.logger.log(
|
||||
`Marked checkout session ${checkoutSessionId} as completed`,
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to mark checkout session as completed:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a checkout session as abandoned (optional - for analytics)
|
||||
*/
|
||||
async markCheckoutAbandoned(checkoutSessionId: string) {
|
||||
try {
|
||||
const result = await this.checkoutSessionModel.updateOne(
|
||||
{ checkoutSessionId },
|
||||
{
|
||||
isAbandoned: true,
|
||||
},
|
||||
)
|
||||
|
||||
if (result.matchedCount > 0) {
|
||||
this.logger.log(
|
||||
`Marked checkout session ${checkoutSessionId} as abandoned`,
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to mark checkout session as abandoned:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
async triggerAbandonedCheckoutProcess() {
|
||||
this.logger.log('Manually triggering abandoned checkout process...')
|
||||
await this.processAbandonedCheckouts()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Injectable } from '@nestjs/common'
|
||||
import { OnEvent } from '@nestjs/event-emitter'
|
||||
import { InjectModel } from '@nestjs/mongoose'
|
||||
import { Model, Types } from 'mongoose'
|
||||
import { MailService } from '../mail/mail.service'
|
||||
import {
|
||||
BillingNotification,
|
||||
BillingNotificationDocument,
|
||||
} from './schemas/billing-notification.schema'
|
||||
import { User, UserDocument } from '../users/schemas/user.schema'
|
||||
|
||||
@Injectable()
|
||||
export class BillingNotificationsListener {
|
||||
constructor(
|
||||
private readonly mailService: MailService,
|
||||
@InjectModel(BillingNotification.name)
|
||||
private readonly notificationModel: Model<BillingNotificationDocument>,
|
||||
@InjectModel(User.name)
|
||||
private readonly userModel: Model<UserDocument>,
|
||||
) {}
|
||||
|
||||
@OnEvent('billing.notification.created', { async: true })
|
||||
async handleCreatedEvent(payload: {
|
||||
notificationId: Types.ObjectId
|
||||
userId: Types.ObjectId
|
||||
type: string
|
||||
title: string
|
||||
message: string
|
||||
meta: Record<string, any>
|
||||
createdAt: Date
|
||||
sendEmail?: boolean
|
||||
}) {
|
||||
if (!payload?.sendEmail) {
|
||||
return
|
||||
}
|
||||
|
||||
const user = await this.userModel.findById(payload.userId)
|
||||
if (!user?.email) {
|
||||
return
|
||||
}
|
||||
|
||||
const subject = this.subjectForType(payload.type, payload.title)
|
||||
const ctaUrlBase = process.env.FRONTEND_URL || 'https://app.textbee.dev'
|
||||
const isEmailVerification = payload.type === 'email_verification_required'
|
||||
const ctaUrl = isEmailVerification
|
||||
? `${ctaUrlBase}/dashboard/account`
|
||||
: 'https://textbee.dev/#pricing'
|
||||
const ctaLabel = isEmailVerification ? 'Verify your email' : 'View plans & pricing'
|
||||
|
||||
await this.mailService.sendEmailFromTemplate({
|
||||
to: user.email,
|
||||
subject,
|
||||
template: 'billing-notification',
|
||||
context: {
|
||||
name: user.name?.split(' ')?.[0] || 'there',
|
||||
title: payload.title,
|
||||
message: payload.message,
|
||||
ctaLabel,
|
||||
ctaUrl,
|
||||
brandName: 'textbee.dev',
|
||||
},
|
||||
from: undefined,
|
||||
})
|
||||
|
||||
await this.notificationModel.updateOne(
|
||||
{ _id: payload.notificationId },
|
||||
{ $inc: { sentEmailCount: 1 }, $set: { lastEmailSentAt: new Date() } },
|
||||
)
|
||||
}
|
||||
|
||||
private subjectForType(type: string, fallback: string) {
|
||||
switch (type) {
|
||||
case 'daily_limit_reached':
|
||||
return 'Daily SMS limit reached — action required'
|
||||
case 'monthly_limit_reached':
|
||||
return 'Monthly SMS limit reached — action required'
|
||||
case 'bulk_sms_limit_reached':
|
||||
return 'Bulk send limit exceeded'
|
||||
case 'daily_limit_approaching':
|
||||
return 'Heads up: daily usage nearing your limit'
|
||||
case 'monthly_limit_approaching':
|
||||
return 'Heads up: monthly usage nearing your limit'
|
||||
case 'email_verification_required':
|
||||
return 'Verify your email to keep using textbee'
|
||||
default:
|
||||
return fallback || 'Account notification'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Injectable } from '@nestjs/common'
|
||||
import { InjectModel } from '@nestjs/mongoose'
|
||||
import { InjectQueue } from '@nestjs/bull'
|
||||
import { Queue } from 'bull'
|
||||
import { Model, Types } from 'mongoose'
|
||||
import {
|
||||
BillingNotification,
|
||||
BillingNotificationDocument,
|
||||
BillingNotificationSchema,
|
||||
BillingNotificationType,
|
||||
} from './schemas/billing-notification.schema'
|
||||
|
||||
type NotifyOnceInput = {
|
||||
userId: Types.ObjectId | string
|
||||
type: BillingNotificationType
|
||||
title: string
|
||||
message: string
|
||||
meta?: Record<string, any>
|
||||
sendEmail?: boolean
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BillingNotificationsService {
|
||||
constructor(
|
||||
@InjectModel(BillingNotification.name)
|
||||
private readonly notificationModel: Model<BillingNotificationDocument>,
|
||||
@InjectQueue('billing-notifications') private readonly billingQueue: Queue,
|
||||
) {}
|
||||
|
||||
async notifyOnce({ userId, type, title, message, meta = {}, sendEmail = true }: NotifyOnceInput) {
|
||||
const windowMs = this.getDedupeWindowMs(type)
|
||||
const existing = await this.notificationModel.findOne({
|
||||
user: new Types.ObjectId(userId),
|
||||
type,
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
const lastSentAt = existing.lastEmailSentAt
|
||||
if (lastSentAt && lastSentAt.getTime() >= Date.now() - windowMs) {
|
||||
return existing
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await this.notificationModel.findOneAndUpdate(
|
||||
{ user: new Types.ObjectId(userId), type },
|
||||
{ $set: { title, message, meta }, $setOnInsert: { user: new Types.ObjectId(userId), type } },
|
||||
{ upsert: true, new: true, setDefaultsOnInsert: true },
|
||||
)
|
||||
|
||||
await this.billingQueue.add(
|
||||
'send',
|
||||
{
|
||||
notificationId: updated._id,
|
||||
userId: updated.user,
|
||||
type: updated.type,
|
||||
title: updated.title,
|
||||
message: updated.message,
|
||||
meta: updated.meta,
|
||||
createdAt: updated.createdAt,
|
||||
sendEmail,
|
||||
},
|
||||
{
|
||||
delay: 30000,
|
||||
attempts: 3,
|
||||
removeOnComplete: false,
|
||||
backoff: { type: 'exponential', delay: 2000 },
|
||||
jobId: updated._id.toString(),
|
||||
},
|
||||
)
|
||||
|
||||
return updated
|
||||
}
|
||||
|
||||
async listForUser(userId: Types.ObjectId | string, { limit = 50 } = {}) {
|
||||
return this.notificationModel
|
||||
.find({ user: new Types.ObjectId(userId) })
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(limit)
|
||||
}
|
||||
|
||||
private getDedupeWindowMs(type: BillingNotificationType) {
|
||||
const hours = {
|
||||
[BillingNotificationType.EMAIL_VERIFICATION_REQUIRED]: 24,
|
||||
[BillingNotificationType.DAILY_LIMIT_REACHED]: 12,
|
||||
[BillingNotificationType.MONTHLY_LIMIT_REACHED]: 48,
|
||||
[BillingNotificationType.BULK_SMS_LIMIT_REACHED]: 12,
|
||||
[BillingNotificationType.DAILY_LIMIT_APPROACHING]: 24,
|
||||
[BillingNotificationType.MONTHLY_LIMIT_APPROACHING]: 48,
|
||||
}[type]
|
||||
|
||||
return hours * 60 * 60 * 1000
|
||||
}
|
||||
|
||||
// upsert-based single-document per user+type; dedupe controlled by window
|
||||
|
||||
}
|
||||
|
||||
export { BillingNotificationType }
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module, forwardRef } from '@nestjs/common'
|
||||
import { BillingController } from './billing.controller'
|
||||
import { BillingService } from './billing.service'
|
||||
import { AbandonedCheckoutService } from './abandoned-checkout.service'
|
||||
import { PlanSchema } from './schemas/plan.schema'
|
||||
import { SubscriptionSchema } from './schemas/subscription.schema'
|
||||
import { Plan } from './schemas/plan.schema'
|
||||
@@ -9,21 +10,45 @@ import { MongooseModule } from '@nestjs/mongoose'
|
||||
import { AuthModule } from 'src/auth/auth.module'
|
||||
import { UsersModule } from 'src/users/users.module'
|
||||
import { GatewayModule } from 'src/gateway/gateway.module'
|
||||
import { MailModule } from 'src/mail/mail.module'
|
||||
import { PolarWebhookPayload, PolarWebhookPayloadSchema } from './schemas/polar-webhook-payload.schema'
|
||||
import { Device, DeviceSchema } from '../gateway/schemas/device.schema'
|
||||
import { CheckoutSession, CheckoutSessionSchema } from './schemas/checkout-session.schema'
|
||||
import { BillingNotification, BillingNotificationSchema } from './schemas/billing-notification.schema'
|
||||
import { BillingNotificationsService } from './billing-notifications.service'
|
||||
// import { BillingNotificationsListener } from './billing-notifications.listener'
|
||||
import { BullModule } from '@nestjs/bull'
|
||||
import { BillingNotificationsProcessor } from 'src/billing/queue/billing-notifications.processor'
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
BullModule.registerQueue({
|
||||
name: 'billing-notifications',
|
||||
defaultJobOptions: {
|
||||
attempts: 2,
|
||||
backoff: {
|
||||
type: 'exponential',
|
||||
delay: 1000,
|
||||
},
|
||||
removeOnComplete: false,
|
||||
removeOnFail: false,
|
||||
},
|
||||
}),
|
||||
MongooseModule.forFeature([
|
||||
{ name: Plan.name, schema: PlanSchema },
|
||||
{ name: Subscription.name, schema: SubscriptionSchema },
|
||||
{ name: PolarWebhookPayload.name, schema: PolarWebhookPayloadSchema },
|
||||
{ name: Device.name, schema: DeviceSchema },
|
||||
{ name: CheckoutSession.name, schema: CheckoutSessionSchema },
|
||||
{ name: BillingNotification.name, schema: BillingNotificationSchema },
|
||||
]),
|
||||
forwardRef(() => AuthModule),
|
||||
forwardRef(() => UsersModule),
|
||||
forwardRef(() => GatewayModule),
|
||||
MailModule,
|
||||
],
|
||||
controllers: [BillingController],
|
||||
providers: [BillingService],
|
||||
exports: [BillingService],
|
||||
providers: [BillingService, AbandonedCheckoutService, BillingNotificationsService, BillingNotificationsProcessor],
|
||||
exports: [BillingService, AbandonedCheckoutService, BillingNotificationsService],
|
||||
})
|
||||
export class BillingModule {}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { HttpException, HttpStatus, Injectable } from '@nestjs/common'
|
||||
import {
|
||||
BadRequestException,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
} from '@nestjs/common'
|
||||
import { InjectModel } from '@nestjs/mongoose'
|
||||
import { Model, Types } from 'mongoose'
|
||||
import { Plan, PlanDocument } from './schemas/plan.schema'
|
||||
@@ -11,11 +16,20 @@ import { User, UserDocument } from '../users/schemas/user.schema'
|
||||
import { CheckoutResponseDTO, PlanDTO } from './billing.dto'
|
||||
import { SMSDocument } from '../gateway/schemas/sms.schema'
|
||||
import { SMS } from '../gateway/schemas/sms.schema'
|
||||
import { Device, DeviceDocument } from '../gateway/schemas/device.schema'
|
||||
import { validateEvent } from '@polar-sh/sdk/webhooks'
|
||||
import {
|
||||
PolarWebhookPayload,
|
||||
PolarWebhookPayloadDocument,
|
||||
} from './schemas/polar-webhook-payload.schema'
|
||||
import {
|
||||
CheckoutSession,
|
||||
CheckoutSessionDocument,
|
||||
} from './schemas/checkout-session.schema'
|
||||
import {
|
||||
BillingNotificationsService,
|
||||
BillingNotificationType,
|
||||
} from './billing-notifications.service'
|
||||
|
||||
@Injectable()
|
||||
export class BillingService {
|
||||
@@ -27,8 +41,12 @@ export class BillingService {
|
||||
private subscriptionModel: Model<SubscriptionDocument>,
|
||||
@InjectModel(User.name) private userModel: Model<UserDocument>,
|
||||
@InjectModel(SMS.name) private smsModel: Model<SMSDocument>,
|
||||
@InjectModel(Device.name) private deviceModel: Model<DeviceDocument>,
|
||||
@InjectModel(PolarWebhookPayload.name)
|
||||
private polarWebhookPayloadModel: Model<PolarWebhookPayloadDocument>,
|
||||
@InjectModel(CheckoutSession.name)
|
||||
private checkoutSessionModel: Model<CheckoutSessionDocument>,
|
||||
private readonly billingNotifications: BillingNotificationsService,
|
||||
) {
|
||||
this.polarApi = new Polar({
|
||||
accessToken: process.env.POLAR_ACCESS_TOKEN ?? '',
|
||||
@@ -51,15 +69,177 @@ export class BillingService {
|
||||
})
|
||||
.populate('plan')
|
||||
|
||||
// Get user's devices and usage data
|
||||
const userDevices = await this.deviceModel.find({ user: user._id }, '_id')
|
||||
const deviceIds = userDevices.map((d) => d._id)
|
||||
|
||||
const processedSmsToday = await this.smsModel.countDocuments({
|
||||
device: { $in: deviceIds },
|
||||
createdAt: { $gte: new Date(new Date().setHours(0, 0, 0, 0)) },
|
||||
})
|
||||
|
||||
const processedSmsLastMonth = await this.smsModel.countDocuments({
|
||||
device: { $in: deviceIds },
|
||||
createdAt: {
|
||||
$gte: new Date(new Date().setMonth(new Date().getMonth() - 1)),
|
||||
},
|
||||
})
|
||||
|
||||
if (subscription) {
|
||||
return subscription
|
||||
const plan = subscription.plan
|
||||
const effectiveLimits = this.getEffectiveLimits(subscription, plan)
|
||||
|
||||
try {
|
||||
if (effectiveLimits.dailyLimit && effectiveLimits.dailyLimit > 0) {
|
||||
const dailyPct = processedSmsToday / effectiveLimits.dailyLimit
|
||||
if (
|
||||
dailyPct >= 0.8 &&
|
||||
processedSmsToday < effectiveLimits.dailyLimit
|
||||
) {
|
||||
this.billingNotifications
|
||||
.notifyOnce({
|
||||
userId: user._id,
|
||||
type: BillingNotificationType.DAILY_LIMIT_APPROACHING,
|
||||
title: "You're nearing today's SMS limit",
|
||||
message: `You've used ${Math.round(dailyPct * 100)}% of today's SMS allocation. ${effectiveLimits.dailyLimit - processedSmsToday} messages remain for today. Consider upgrading your plan or scheduling sends for later.`,
|
||||
meta: {
|
||||
processedSmsToday,
|
||||
dailyLimit: effectiveLimits.dailyLimit,
|
||||
},
|
||||
sendEmail: true,
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
}
|
||||
if (effectiveLimits.monthlyLimit && effectiveLimits.monthlyLimit > 0) {
|
||||
const monthlyPct =
|
||||
processedSmsLastMonth / effectiveLimits.monthlyLimit
|
||||
if (
|
||||
monthlyPct >= 0.8 &&
|
||||
processedSmsLastMonth < effectiveLimits.monthlyLimit
|
||||
) {
|
||||
this.billingNotifications
|
||||
.notifyOnce({
|
||||
userId: user._id,
|
||||
type: BillingNotificationType.MONTHLY_LIMIT_APPROACHING,
|
||||
title: "You're nearing this month's SMS limit",
|
||||
message: `You've used ${Math.round(monthlyPct * 100)}% of this month's SMS allocation. ${effectiveLimits.monthlyLimit - processedSmsLastMonth} messages remain this billing period. Upgrade to increase your monthly capacity.`,
|
||||
meta: {
|
||||
processedSmsLastMonth,
|
||||
monthlyLimit: effectiveLimits.monthlyLimit,
|
||||
},
|
||||
sendEmail: true,
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return {
|
||||
...subscription.toObject(),
|
||||
usage: {
|
||||
processedSmsToday,
|
||||
processedSmsLastMonth,
|
||||
dailyLimit: effectiveLimits.dailyLimit,
|
||||
monthlyLimit: effectiveLimits.monthlyLimit,
|
||||
dailyRemaining:
|
||||
effectiveLimits.dailyLimit === -1
|
||||
? -1
|
||||
: effectiveLimits.dailyLimit - processedSmsToday,
|
||||
monthlyRemaining:
|
||||
effectiveLimits.monthlyLimit === -1
|
||||
? -1
|
||||
: effectiveLimits.monthlyLimit - processedSmsLastMonth,
|
||||
dailyUsagePercentage:
|
||||
effectiveLimits.dailyLimit === -1
|
||||
? 0
|
||||
: Math.round(
|
||||
(processedSmsToday / effectiveLimits.dailyLimit) * 100,
|
||||
),
|
||||
monthlyUsagePercentage:
|
||||
effectiveLimits.monthlyLimit === -1
|
||||
? 0
|
||||
: Math.round(
|
||||
(processedSmsLastMonth / effectiveLimits.monthlyLimit) * 100,
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const plan = await this.planModel.findOne({ name: 'free' })
|
||||
const effectiveLimits = this.getEffectiveLimits(null, plan)
|
||||
|
||||
// fire-and-forget: approaching threshold notifications
|
||||
try {
|
||||
if (effectiveLimits.dailyLimit && effectiveLimits.dailyLimit > 0) {
|
||||
const dailyPct = processedSmsToday / effectiveLimits.dailyLimit
|
||||
if (dailyPct >= 0.8 && processedSmsToday < effectiveLimits.dailyLimit) {
|
||||
this.billingNotifications
|
||||
.notifyOnce({
|
||||
userId: user._id,
|
||||
type: BillingNotificationType.DAILY_LIMIT_APPROACHING,
|
||||
title: "You're nearing today's SMS limit",
|
||||
message: `You've used ${Math.round(dailyPct * 100)}% of today's SMS allocation. ${effectiveLimits.dailyLimit - processedSmsToday} messages remain for today. Consider upgrading your plan or scheduling sends for later.`,
|
||||
meta: {
|
||||
processedSmsToday,
|
||||
dailyLimit: effectiveLimits.dailyLimit,
|
||||
},
|
||||
sendEmail: true,
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
}
|
||||
if (effectiveLimits.monthlyLimit && effectiveLimits.monthlyLimit > 0) {
|
||||
const monthlyPct = processedSmsLastMonth / effectiveLimits.monthlyLimit
|
||||
if (
|
||||
monthlyPct >= 0.8 &&
|
||||
processedSmsLastMonth < effectiveLimits.monthlyLimit
|
||||
) {
|
||||
this.billingNotifications
|
||||
.notifyOnce({
|
||||
userId: user._id,
|
||||
type: BillingNotificationType.MONTHLY_LIMIT_APPROACHING,
|
||||
title: "You're nearing this month's SMS limit",
|
||||
message: `You've used ${Math.round(monthlyPct * 100)}% of this month's SMS allocation. ${effectiveLimits.monthlyLimit - processedSmsLastMonth} messages remain this billing period. Upgrade to increase your monthly capacity.`,
|
||||
meta: {
|
||||
processedSmsLastMonth,
|
||||
monthlyLimit: effectiveLimits.monthlyLimit,
|
||||
},
|
||||
sendEmail: true,
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return {
|
||||
plan,
|
||||
isActive: true,
|
||||
usage: {
|
||||
processedSmsToday,
|
||||
processedSmsLastMonth,
|
||||
dailyLimit: effectiveLimits.dailyLimit,
|
||||
monthlyLimit: effectiveLimits.monthlyLimit,
|
||||
dailyRemaining:
|
||||
effectiveLimits.dailyLimit === -1
|
||||
? -1
|
||||
: effectiveLimits.dailyLimit - processedSmsToday,
|
||||
monthlyRemaining:
|
||||
effectiveLimits.monthlyLimit === -1
|
||||
? -1
|
||||
: effectiveLimits.monthlyLimit - processedSmsLastMonth,
|
||||
dailyUsagePercentage:
|
||||
effectiveLimits.dailyLimit === -1
|
||||
? 0
|
||||
: Math.round(
|
||||
(processedSmsToday / effectiveLimits.dailyLimit) * 100,
|
||||
),
|
||||
monthlyUsagePercentage:
|
||||
effectiveLimits.monthlyLimit === -1
|
||||
? 0
|
||||
: Math.round(
|
||||
(processedSmsLastMonth / effectiveLimits.monthlyLimit) * 100,
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,21 +254,38 @@ export class BillingService {
|
||||
}): Promise<CheckoutResponseDTO> {
|
||||
const isYearly = payload.isYearly
|
||||
|
||||
const existingCheckoutSession = await this.checkoutSessionModel.findOne({
|
||||
user: user._id,
|
||||
expiresAt: { $gt: new Date() },
|
||||
})
|
||||
|
||||
if (existingCheckoutSession) {
|
||||
return { redirectUrl: existingCheckoutSession.checkoutUrl }
|
||||
}
|
||||
|
||||
const selectedPlan = await this.planModel.findOne({
|
||||
name: payload.planName,
|
||||
})
|
||||
|
||||
const currentSubscription = await this.getCurrentSubscription(user)
|
||||
if ((currentSubscription?.plan as Plan)?.name === payload.planName) {
|
||||
throw new BadRequestException({
|
||||
message: `You are already on ${payload.planName} plan, please contact billing@textbee.dev to get a custom plan`,
|
||||
code: 'ALREADY_ON_PLAN',
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
!selectedPlan?.polarMonthlyProductId &&
|
||||
!selectedPlan?.polarYearlyProductId
|
||||
) {
|
||||
throw new Error('Plan cannot be purchased')
|
||||
throw new BadRequestException('Plan cannot be purchased')
|
||||
}
|
||||
|
||||
// const product = await this.polarApi.products.get(selectedPlan.polarProductId)
|
||||
|
||||
const discountId =
|
||||
payload.discountId ?? '48f62ff7-3cd8-46ec-8ca7-2e570dc9c522'
|
||||
payload.discountId ?? process.env.POLAR_DEFAULT_DISCOUNT_ID
|
||||
|
||||
try {
|
||||
const checkoutOptions: any = {
|
||||
@@ -97,24 +294,48 @@ export class BillingService {
|
||||
selectedPlan.polarMonthlyProductId,
|
||||
selectedPlan.polarYearlyProductId,
|
||||
],
|
||||
successUrl: `${process.env.FRONTEND_URL}/dashboard?checkout-success=1&checkout_id={CHECKOUT_ID}`,
|
||||
cancelUrl: `${process.env.FRONTEND_URL}/dashboard?checkout-cancel=1&checkout_id={CHECKOUT_ID}`,
|
||||
successUrl: `${process.env.FRONTEND_URL}/dashboard/account?checkout-success=1&checkout_id={CHECKOUT_ID}`,
|
||||
cancelUrl: `${process.env.FRONTEND_URL}/dashboard/account?checkout-cancel=1&checkout_id={CHECKOUT_ID}`,
|
||||
customerEmail: user.email,
|
||||
customerName: user.name,
|
||||
customerIpAddress: req.ip,
|
||||
metadata: {
|
||||
userId: user._id?.toString(),
|
||||
},
|
||||
externalCustomerId: user._id?.toString(),
|
||||
}
|
||||
const discount = await this.polarApi.discounts.get({
|
||||
id: discountId,
|
||||
})
|
||||
if (discount) {
|
||||
checkoutOptions.discountId = discount.id
|
||||
|
||||
try {
|
||||
const discount = await this.polarApi.discounts.get({
|
||||
id: discountId,
|
||||
})
|
||||
if (discount) {
|
||||
checkoutOptions.discountId = discount.id
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('failed to get discount', error)
|
||||
}
|
||||
|
||||
const checkout = await this.polarApi.checkouts.create(checkoutOptions)
|
||||
console.log(checkout)
|
||||
|
||||
this.checkoutSessionModel
|
||||
.updateOne(
|
||||
{
|
||||
user: user._id,
|
||||
},
|
||||
{
|
||||
user: user._id,
|
||||
checkoutSessionId: checkout.id,
|
||||
checkoutUrl: checkout.url,
|
||||
expiresAt: new Date(checkout.expiresAt),
|
||||
payload: checkout,
|
||||
},
|
||||
{ upsert: true },
|
||||
)
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
})
|
||||
|
||||
return { redirectUrl: checkout.url }
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
@@ -178,6 +399,22 @@ export class BillingService {
|
||||
}
|
||||
}
|
||||
|
||||
private getEffectiveLimits(subscription: any, plan: any) {
|
||||
if (!subscription) {
|
||||
return {
|
||||
dailyLimit: plan.dailyLimit,
|
||||
monthlyLimit: plan.monthlyLimit,
|
||||
bulkSendLimit: plan.bulkSendLimit,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
dailyLimit: subscription.customDailyLimit ?? plan.dailyLimit,
|
||||
monthlyLimit: subscription.customMonthlyLimit ?? plan.monthlyLimit,
|
||||
bulkSendLimit: subscription.customBulkSendLimit ?? plan.bulkSendLimit,
|
||||
}
|
||||
}
|
||||
|
||||
async getUserLimits(userId: string) {
|
||||
const subscription = await this.subscriptionModel
|
||||
.findOne({ user: new Types.ObjectId(userId), isActive: true })
|
||||
@@ -186,21 +423,11 @@ export class BillingService {
|
||||
if (!subscription) {
|
||||
// Default to free plan limits
|
||||
const freePlan = await this.planModel.findOne({ name: 'free' })
|
||||
return {
|
||||
dailyLimit: freePlan.dailyLimit,
|
||||
monthlyLimit: freePlan.monthlyLimit,
|
||||
bulkSendLimit: freePlan.bulkSendLimit,
|
||||
}
|
||||
return this.getEffectiveLimits(null, freePlan)
|
||||
}
|
||||
|
||||
// For custom plans, use custom limits if set
|
||||
return {
|
||||
dailyLimit: subscription.customDailyLimit || subscription.plan.dailyLimit,
|
||||
monthlyLimit:
|
||||
subscription.customMonthlyLimit || subscription.plan.monthlyLimit,
|
||||
bulkSendLimit:
|
||||
subscription.customBulkSendLimit || subscription.plan.bulkSendLimit,
|
||||
}
|
||||
// Use custom limits if set, otherwise fall back to plan limits
|
||||
return this.getEffectiveLimits(subscription, subscription.plan)
|
||||
}
|
||||
|
||||
async switchPlan({
|
||||
@@ -295,7 +522,7 @@ export class BillingService {
|
||||
{
|
||||
message: 'Sorry, we cannot process your request at the moment',
|
||||
},
|
||||
HttpStatus.BAD_REQUEST,
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -311,7 +538,7 @@ export class BillingService {
|
||||
|
||||
let plan: PlanDocument
|
||||
const subscription = await this.subscriptionModel.findOne({
|
||||
user,
|
||||
user: user._id,
|
||||
isActive: true,
|
||||
})
|
||||
|
||||
@@ -321,48 +548,69 @@ export class BillingService {
|
||||
plan = await this.planModel.findById(subscription.plan)
|
||||
}
|
||||
|
||||
const effectiveLimits = this.getEffectiveLimits(subscription, plan)
|
||||
|
||||
if (plan.name?.startsWith('custom')) {
|
||||
// TODO: for now custom plans are unlimited
|
||||
return true
|
||||
// For custom plans, check if custom limits are set to unlimited (-1)
|
||||
if (
|
||||
effectiveLimits.dailyLimit === -1 &&
|
||||
effectiveLimits.monthlyLimit === -1 &&
|
||||
effectiveLimits.bulkSendLimit === -1
|
||||
) {
|
||||
return true
|
||||
}
|
||||
// Otherwise, continue with limit checks using effective limits
|
||||
}
|
||||
|
||||
let hasReachedLimit = false
|
||||
let message = ''
|
||||
|
||||
// Get user's devices and then count SMS
|
||||
const userDevices = await this.deviceModel.find({ user: user._id }, '_id')
|
||||
const deviceIds = userDevices.map((d) => d._id)
|
||||
|
||||
const processedSmsToday = await this.smsModel.countDocuments({
|
||||
'device.user': userId,
|
||||
device: { $in: deviceIds },
|
||||
createdAt: { $gte: new Date(new Date().setHours(0, 0, 0, 0)) },
|
||||
})
|
||||
const processedSmsLastMonth = await this.smsModel.countDocuments({
|
||||
'device.user': userId,
|
||||
device: { $in: deviceIds },
|
||||
createdAt: {
|
||||
$gte: new Date(new Date().setMonth(new Date().getMonth() - 1)),
|
||||
},
|
||||
})
|
||||
|
||||
let dailyExceeded = false
|
||||
let monthlyExceeded = false
|
||||
let bulkExceeded = false
|
||||
|
||||
if (['send_sms', 'receive_sms', 'bulk_send_sms'].includes(action)) {
|
||||
// check daily limit
|
||||
if (
|
||||
plan.dailyLimit !== -1 &&
|
||||
processedSmsToday + value > plan.dailyLimit
|
||||
) {
|
||||
const dailyFinite = effectiveLimits.dailyLimit !== -1
|
||||
const monthlyFinite = effectiveLimits.monthlyLimit !== -1
|
||||
|
||||
// exceeded checks
|
||||
dailyExceeded =
|
||||
dailyFinite && processedSmsToday + value > effectiveLimits.dailyLimit
|
||||
monthlyExceeded =
|
||||
monthlyFinite &&
|
||||
processedSmsLastMonth + value > effectiveLimits.monthlyLimit
|
||||
bulkExceeded =
|
||||
effectiveLimits.bulkSendLimit !== -1 &&
|
||||
value > effectiveLimits.bulkSendLimit
|
||||
|
||||
if (dailyExceeded) {
|
||||
hasReachedLimit = true
|
||||
message = `You have reached your daily limit, you only have ${plan.dailyLimit - processedSmsToday} remaining`
|
||||
message = `Daily SMS limit reached — you've used your full daily allocation. ${Math.max(0, effectiveLimits.dailyLimit - processedSmsToday)} messages remain for today. Upgrade to increase your daily capacity or try again tomorrow.`
|
||||
}
|
||||
|
||||
// check monthly limit
|
||||
if (
|
||||
plan.monthlyLimit !== -1 &&
|
||||
processedSmsLastMonth + value > plan.monthlyLimit
|
||||
) {
|
||||
if (monthlyExceeded) {
|
||||
hasReachedLimit = true
|
||||
message = `You have reached your monthly limit, you only have ${plan.monthlyLimit - processedSmsLastMonth} remaining`
|
||||
message = `Monthly SMS limit reached — you've used this billing period's allocation. Upgrade to continue sending immediately, or wait for the next billing period.`
|
||||
}
|
||||
|
||||
// check bulk send limit
|
||||
if (plan.bulkSendLimit !== -1 && value > plan.bulkSendLimit) {
|
||||
if (bulkExceeded) {
|
||||
hasReachedLimit = true
|
||||
message = `You can only send ${plan.bulkSendLimit} sms at a time`
|
||||
message = `Bulk send limit exceeded — your plan allows up to ${effectiveLimits.bulkSendLimit} messages per batch. Split your send into smaller batches or upgrade your plan.`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,23 +625,71 @@ export class BillingService {
|
||||
value,
|
||||
message,
|
||||
hasReachedLimit: true,
|
||||
dailyLimit: plan.dailyLimit,
|
||||
dailyRemaining: plan.dailyLimit - processedSmsToday,
|
||||
monthlyRemaining: plan.monthlyLimit - processedSmsLastMonth,
|
||||
bulkSendLimit: plan.bulkSendLimit,
|
||||
monthlyLimit: plan.monthlyLimit,
|
||||
dailyLimit: effectiveLimits.dailyLimit,
|
||||
dailyRemaining: effectiveLimits.dailyLimit - processedSmsToday,
|
||||
monthlyRemaining:
|
||||
effectiveLimits.monthlyLimit - processedSmsLastMonth,
|
||||
bulkSendLimit: effectiveLimits.bulkSendLimit,
|
||||
monthlyLimit: effectiveLimits.monthlyLimit,
|
||||
}),
|
||||
)
|
||||
|
||||
let type: BillingNotificationType
|
||||
let titleForEmail = ''
|
||||
if (dailyExceeded) {
|
||||
type = BillingNotificationType.DAILY_LIMIT_REACHED
|
||||
titleForEmail = 'Daily SMS limit reached'
|
||||
} else if (monthlyExceeded) {
|
||||
type = BillingNotificationType.MONTHLY_LIMIT_REACHED
|
||||
titleForEmail = 'Monthly SMS limit reached'
|
||||
} else if (bulkExceeded) {
|
||||
type = BillingNotificationType.BULK_SMS_LIMIT_REACHED
|
||||
titleForEmail = 'Bulk send limit exceeded'
|
||||
}
|
||||
if (type) {
|
||||
await this.billingNotifications.notifyOnce({
|
||||
userId: user._id,
|
||||
type,
|
||||
title: titleForEmail || 'Usage limit notice',
|
||||
message,
|
||||
meta: {
|
||||
processedSmsToday,
|
||||
processedSmsLastMonth,
|
||||
attempted: value,
|
||||
dailyLimit: effectiveLimits.dailyLimit,
|
||||
monthlyLimit: effectiveLimits.monthlyLimit,
|
||||
bulkSendLimit: effectiveLimits.bulkSendLimit,
|
||||
},
|
||||
sendEmail: true,
|
||||
})
|
||||
}
|
||||
|
||||
// if plan is not free and monthly limit is exceeded, give them 80% more monthly limit
|
||||
if (
|
||||
plan.name !== 'free' &&
|
||||
monthlyExceeded &&
|
||||
!dailyExceeded &&
|
||||
!bulkExceeded
|
||||
) {
|
||||
const extendedMonthlyLimit = Math.floor(
|
||||
effectiveLimits.monthlyLimit * 1.8,
|
||||
)
|
||||
const exceedsExtended =
|
||||
processedSmsLastMonth + value > extendedMonthlyLimit
|
||||
if (!exceedsExtended) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
throw new HttpException(
|
||||
{
|
||||
message: message,
|
||||
hasReachedLimit: true,
|
||||
dailyLimit: plan.dailyLimit,
|
||||
dailyRemaining: plan.dailyLimit - processedSmsToday,
|
||||
monthlyRemaining: plan.monthlyLimit - processedSmsLastMonth,
|
||||
bulkSendLimit: plan.bulkSendLimit,
|
||||
monthlyLimit: plan.monthlyLimit,
|
||||
dailyLimit: effectiveLimits.dailyLimit,
|
||||
dailyRemaining: effectiveLimits.dailyLimit - processedSmsToday,
|
||||
monthlyRemaining:
|
||||
effectiveLimits.monthlyLimit - processedSmsLastMonth,
|
||||
bulkSendLimit: effectiveLimits.bulkSendLimit,
|
||||
monthlyLimit: effectiveLimits.monthlyLimit,
|
||||
},
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
)
|
||||
@@ -401,10 +697,7 @@ export class BillingService {
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof HttpException &&
|
||||
error.getStatus() === HttpStatus.TOO_MANY_REQUESTS
|
||||
) {
|
||||
if (error instanceof HttpException) {
|
||||
throw error
|
||||
}
|
||||
console.error('canPerformAction: Exception in canPerformAction')
|
||||
@@ -415,19 +708,32 @@ export class BillingService {
|
||||
|
||||
async getUsage(userId: string) {
|
||||
const subscription = await this.subscriptionModel.findOne({
|
||||
user: userId,
|
||||
user: new Types.ObjectId(userId),
|
||||
isActive: true,
|
||||
})
|
||||
|
||||
const plan = await this.planModel.findById(subscription.plan)
|
||||
let plan: PlanDocument
|
||||
if (!subscription) {
|
||||
plan = await this.planModel.findOne({ name: 'free' })
|
||||
} else {
|
||||
plan = await this.planModel.findById(subscription.plan)
|
||||
}
|
||||
|
||||
const effectiveLimits = this.getEffectiveLimits(subscription, plan)
|
||||
|
||||
// First get all devices belonging to the user
|
||||
const userDevices = await this.deviceModel
|
||||
.find({ user: new Types.ObjectId(userId) })
|
||||
.select('_id')
|
||||
const deviceIds = userDevices.map((device) => device._id)
|
||||
|
||||
const processedSmsToday = await this.smsModel.countDocuments({
|
||||
'device.user': userId,
|
||||
device: { $in: deviceIds },
|
||||
createdAt: { $gte: new Date(new Date().setHours(0, 0, 0, 0)) },
|
||||
})
|
||||
|
||||
const processedSmsLastMonth = await this.smsModel.countDocuments({
|
||||
'device.user': userId,
|
||||
device: { $in: deviceIds },
|
||||
createdAt: {
|
||||
$gte: new Date(new Date().setMonth(new Date().getMonth() - 1)),
|
||||
},
|
||||
@@ -436,11 +742,17 @@ export class BillingService {
|
||||
return {
|
||||
processedSmsToday,
|
||||
processedSmsLastMonth,
|
||||
dailyLimit: plan.dailyLimit,
|
||||
monthlyLimit: plan.monthlyLimit,
|
||||
bulkSendLimit: plan.bulkSendLimit,
|
||||
dailyRemaining: plan.dailyLimit - processedSmsToday,
|
||||
monthlyRemaining: plan.monthlyLimit - processedSmsLastMonth,
|
||||
dailyLimit: effectiveLimits.dailyLimit,
|
||||
monthlyLimit: effectiveLimits.monthlyLimit,
|
||||
bulkSendLimit: effectiveLimits.bulkSendLimit,
|
||||
dailyRemaining:
|
||||
effectiveLimits.dailyLimit === -1
|
||||
? -1
|
||||
: effectiveLimits.dailyLimit - processedSmsToday,
|
||||
monthlyRemaining:
|
||||
effectiveLimits.monthlyLimit === -1
|
||||
? -1
|
||||
: effectiveLimits.monthlyLimit - processedSmsLastMonth,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Process, Processor } from '@nestjs/bull'
|
||||
import { InjectModel } from '@nestjs/mongoose'
|
||||
import { Job } from 'bull'
|
||||
import { Model, Types } from 'mongoose'
|
||||
import { MailService } from '../../mail/mail.service'
|
||||
import { User, UserDocument } from '../../users/schemas/user.schema'
|
||||
import {
|
||||
BillingNotification,
|
||||
BillingNotificationDocument,
|
||||
} from '../schemas/billing-notification.schema'
|
||||
|
||||
@Processor('billing-notifications')
|
||||
export class BillingNotificationsProcessor {
|
||||
constructor(
|
||||
private readonly mailService: MailService,
|
||||
@InjectModel(BillingNotification.name)
|
||||
private readonly notificationModel: Model<BillingNotificationDocument>,
|
||||
@InjectModel(User.name)
|
||||
private readonly userModel: Model<UserDocument>,
|
||||
) {}
|
||||
|
||||
@Process({ name: 'send', concurrency: 1 })
|
||||
async handleSend(job: Job<{
|
||||
notificationId: Types.ObjectId
|
||||
userId: Types.ObjectId
|
||||
type: string
|
||||
title: string
|
||||
message: string
|
||||
meta: Record<string, any>
|
||||
createdAt: Date
|
||||
sendEmail?: boolean
|
||||
}>) {
|
||||
const payload = job.data
|
||||
if (!payload?.sendEmail) {
|
||||
return
|
||||
}
|
||||
|
||||
const user = await this.userModel.findById(payload.userId)
|
||||
if (!user?.email) {
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure we do not resend within the dedupe window
|
||||
const notif = await this.notificationModel.findById(payload.notificationId)
|
||||
if (!notif) return
|
||||
const windowMs = this.getDedupeWindowMs(payload.type as any)
|
||||
const lastSentAt = notif.lastEmailSentAt
|
||||
if (lastSentAt && lastSentAt.getTime() >= Date.now() - windowMs) {
|
||||
return
|
||||
}
|
||||
|
||||
const subject = this.subjectForType(payload.type, payload.title)
|
||||
const ctaUrlBase = process.env.FRONTEND_URL || 'https://app.textbee.dev'
|
||||
const isEmailVerification = payload.type === 'email_verification_required'
|
||||
const ctaUrl = isEmailVerification
|
||||
? `${ctaUrlBase}/dashboard/account`
|
||||
: 'https://textbee.dev/#pricing'
|
||||
const ctaLabel = isEmailVerification ? 'Verify your email' : 'View plans & pricing'
|
||||
|
||||
await this.mailService.sendEmailFromTemplate({
|
||||
to: user.email,
|
||||
subject,
|
||||
template: 'billing-notification',
|
||||
context: {
|
||||
name: user.name?.split(' ')?.[0] || 'there',
|
||||
title: payload.title,
|
||||
message: payload.message,
|
||||
ctaLabel,
|
||||
ctaUrl,
|
||||
brandName: 'textbee.dev',
|
||||
},
|
||||
from: undefined,
|
||||
})
|
||||
|
||||
await this.notificationModel.updateOne(
|
||||
{ _id: payload.notificationId },
|
||||
{ $inc: { sentEmailCount: 1 }, $set: { lastEmailSentAt: new Date() } },
|
||||
)
|
||||
}
|
||||
|
||||
private getDedupeWindowMs(type: string) {
|
||||
const map: Record<string, number> = {
|
||||
email_verification_required: 24,
|
||||
daily_limit_reached: 12,
|
||||
monthly_limit_reached: 48,
|
||||
bulk_sms_limit_reached: 12,
|
||||
daily_limit_approaching: 24,
|
||||
monthly_limit_approaching: 48,
|
||||
}
|
||||
const hours = map[type] ?? 24
|
||||
return hours * 60 * 60 * 1000
|
||||
}
|
||||
|
||||
private subjectForType(type: string, fallback: string) {
|
||||
switch (type) {
|
||||
case 'daily_limit_reached':
|
||||
return 'Daily SMS limit reached — action required'
|
||||
case 'monthly_limit_reached':
|
||||
return 'Monthly SMS limit reached — action required'
|
||||
case 'bulk_sms_limit_reached':
|
||||
return 'Bulk send limit exceeded'
|
||||
case 'daily_limit_approaching':
|
||||
return 'Heads up: daily usage nearing your limit'
|
||||
case 'monthly_limit_approaching':
|
||||
return 'Heads up: monthly usage nearing your limit'
|
||||
case 'email_verification_required':
|
||||
return 'Verify your email to keep using textbee'
|
||||
default:
|
||||
return fallback || 'Account notification'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
|
||||
import { Document, Types } from 'mongoose'
|
||||
import { User } from '../../users/schemas/user.schema'
|
||||
|
||||
export type BillingNotificationDocument = BillingNotification & Document
|
||||
|
||||
export enum BillingNotificationType {
|
||||
EMAIL_VERIFICATION_REQUIRED = 'email_verification_required',
|
||||
DAILY_LIMIT_REACHED = 'daily_limit_reached',
|
||||
MONTHLY_LIMIT_REACHED = 'monthly_limit_reached',
|
||||
BULK_SMS_LIMIT_REACHED = 'bulk_sms_limit_reached',
|
||||
DAILY_LIMIT_APPROACHING = 'daily_limit_approaching',
|
||||
MONTHLY_LIMIT_APPROACHING = 'monthly_limit_approaching',
|
||||
}
|
||||
|
||||
@Schema({ timestamps: true })
|
||||
export class BillingNotification {
|
||||
_id?: Types.ObjectId
|
||||
|
||||
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
|
||||
user: User | Types.ObjectId
|
||||
|
||||
@Prop({ type: String, enum: Object.values(BillingNotificationType), required: true, index: true })
|
||||
type: BillingNotificationType
|
||||
|
||||
@Prop({ type: String, required: true })
|
||||
title: string
|
||||
|
||||
@Prop({ type: String, required: true })
|
||||
message: string
|
||||
|
||||
@Prop({ type: Object, default: {} })
|
||||
meta: Record<string, any>
|
||||
|
||||
@Prop({ type: Date })
|
||||
readAt?: Date
|
||||
|
||||
@Prop({ type: Boolean, default: false })
|
||||
isDismissed: boolean
|
||||
|
||||
@Prop({ type: Number, default: 0 })
|
||||
sentEmailCount: number
|
||||
|
||||
@Prop({ type: Date })
|
||||
lastEmailSentAt?: Date
|
||||
|
||||
// present because of timestamps: true
|
||||
@Prop({ type: Date })
|
||||
createdAt?: Date
|
||||
|
||||
@Prop({ type: Date })
|
||||
updatedAt?: Date
|
||||
}
|
||||
|
||||
export const BillingNotificationSchema =
|
||||
SchemaFactory.createForClass(BillingNotification)
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
|
||||
import { Document, Types } from 'mongoose'
|
||||
import { User } from '../../users/schemas/user.schema'
|
||||
|
||||
export type CheckoutSessionDocument = CheckoutSession & Document
|
||||
|
||||
export interface AbandonedEmailRecord {
|
||||
emailType: 'first_reminder' | 'second_reminder' | 'third_reminder' | 'final_reminder' | 'last_chance'
|
||||
sentAt: Date
|
||||
emailSubject: string
|
||||
}
|
||||
|
||||
@Schema({ timestamps: true })
|
||||
export class CheckoutSession {
|
||||
_id?: Types.ObjectId
|
||||
|
||||
@Prop({ type: Types.ObjectId, ref: User.name, required: true })
|
||||
user: User
|
||||
|
||||
@Prop({ type: String, required: true })
|
||||
checkoutSessionId: string
|
||||
|
||||
@Prop({ type: String, required: true })
|
||||
checkoutUrl: string
|
||||
|
||||
@Prop({ type: Date, required: true })
|
||||
expiresAt: Date
|
||||
|
||||
@Prop({ type: Object, required: true, default: {} })
|
||||
payload: any
|
||||
|
||||
// Abandoned checkout email tracking
|
||||
@Prop({
|
||||
type: [{
|
||||
emailType: { type: String, enum: ['first_reminder', 'second_reminder', 'third_reminder', 'final_reminder', 'last_chance'] },
|
||||
sentAt: { type: Date },
|
||||
emailSubject: { type: String }
|
||||
}],
|
||||
default: []
|
||||
})
|
||||
abandonedEmails: AbandonedEmailRecord[]
|
||||
|
||||
@Prop({ type: Boolean, default: false })
|
||||
isCompleted: boolean
|
||||
|
||||
@Prop({ type: Boolean, default: false })
|
||||
isAbandoned: boolean
|
||||
|
||||
@Prop({ type: Date })
|
||||
completedAt?: Date
|
||||
}
|
||||
|
||||
export const CheckoutSessionSchema = SchemaFactory.createForClass(CheckoutSession)
|
||||
@@ -18,10 +18,10 @@ export type SubscriptionDocument = Subscription & Document
|
||||
@Schema({ timestamps: true })
|
||||
export class Subscription {
|
||||
@Prop({ type: Types.ObjectId, ref: User.name, required: true })
|
||||
user: User
|
||||
user: User | Types.ObjectId
|
||||
|
||||
@Prop({ type: Types.ObjectId, ref: Plan.name, required: true })
|
||||
plan: Plan
|
||||
plan: Plan | Types.ObjectId
|
||||
|
||||
// @Prop()
|
||||
// polarSubscriptionId?: string
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { TurnstileService } from './turnstile.service'
|
||||
|
||||
@Module({
|
||||
providers: [TurnstileService],
|
||||
exports: [TurnstileService],
|
||||
})
|
||||
export class CommonModule {}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { HttpException, HttpStatus, Injectable } from '@nestjs/common'
|
||||
import axios from 'axios'
|
||||
|
||||
@Injectable()
|
||||
export class TurnstileService {
|
||||
async verify(token: string) {
|
||||
if (!token) {
|
||||
throw new HttpException(
|
||||
{ error: 'Bot verification is required' },
|
||||
HttpStatus.BAD_REQUEST,
|
||||
)
|
||||
}
|
||||
|
||||
if (!process.env.CLOUDFLARE_TURNSTILE_SECRET_KEY) {
|
||||
throw new HttpException(
|
||||
{ error: 'Turnstile secret key is not configured' },
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.append('secret', process.env.CLOUDFLARE_TURNSTILE_SECRET_KEY)
|
||||
params.append('response', token)
|
||||
|
||||
const response = await axios.post(
|
||||
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
|
||||
params,
|
||||
{
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
},
|
||||
)
|
||||
|
||||
if (!response.data?.success) {
|
||||
throw new HttpException(
|
||||
{ error: 'Bot verification failed. Please try again.' },
|
||||
HttpStatus.BAD_REQUEST,
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) {
|
||||
throw error
|
||||
}
|
||||
throw new HttpException(
|
||||
{ error: 'Unable to verify bot check. Please try again.' },
|
||||
HttpStatus.BAD_REQUEST,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
SendBulkSMSInputDTO,
|
||||
SendSMSInputDTO,
|
||||
UpdateSMSStatusDTO,
|
||||
HeartbeatInputDTO,
|
||||
HeartbeatResponseDTO,
|
||||
} from './gateway.dto'
|
||||
import { GatewayService } from './gateway.service'
|
||||
import { CanModifyDevice } from './guards/can-modify-device.guard'
|
||||
@@ -70,6 +72,18 @@ export class GatewayController {
|
||||
return { data }
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'Device heartbeat' })
|
||||
@UseGuards(AuthGuard, CanModifyDevice)
|
||||
@Post('/devices/:id/heartbeat')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async heartbeat(
|
||||
@Param('id') deviceId: string,
|
||||
@Body() input: HeartbeatInputDTO,
|
||||
): Promise<HeartbeatResponseDTO> {
|
||||
const data = await this.gatewayService.heartbeat(deviceId, input)
|
||||
return data
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'Delete device' })
|
||||
@UseGuards(AuthGuard, CanModifyDevice)
|
||||
@Delete('/devices/:id')
|
||||
@@ -170,7 +184,7 @@ export class GatewayController {
|
||||
@Param('id') deviceId: string,
|
||||
@Param('smsId') smsId: string,
|
||||
) {
|
||||
const data = await this.gatewayService.getSMSById(deviceId, smsId);
|
||||
const data = await this.gatewayService.getSMSById(smsId);
|
||||
return { data };
|
||||
}
|
||||
|
||||
@@ -181,7 +195,7 @@ export class GatewayController {
|
||||
@Param('id') deviceId: string,
|
||||
@Param('smsBatchId') smsBatchId: string,
|
||||
) {
|
||||
const data = await this.gatewayService.getSmsBatchById(deviceId, smsBatchId);
|
||||
const data = await this.gatewayService.getSmsBatchById(smsBatchId);
|
||||
return { data };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,45 @@
|
||||
import { ApiProperty } from '@nestjs/swagger'
|
||||
|
||||
export class SimInfoDTO {
|
||||
@ApiProperty({ type: Number, required: true })
|
||||
subscriptionId: number
|
||||
|
||||
@ApiProperty({ type: String, required: false })
|
||||
iccId?: string
|
||||
|
||||
@ApiProperty({ type: Number, required: false })
|
||||
cardId?: number
|
||||
|
||||
@ApiProperty({ type: String, required: false })
|
||||
carrierName?: string
|
||||
|
||||
@ApiProperty({ type: String, required: false })
|
||||
displayName?: string
|
||||
|
||||
@ApiProperty({ type: Number, required: false })
|
||||
simSlotIndex?: number
|
||||
|
||||
@ApiProperty({ type: String, required: false })
|
||||
mcc?: string
|
||||
|
||||
@ApiProperty({ type: String, required: false })
|
||||
mnc?: string
|
||||
|
||||
@ApiProperty({ type: String, required: false })
|
||||
countryIso?: string
|
||||
|
||||
@ApiProperty({ type: String, required: false, enum: ['PHYSICAL_SIM', 'ESIM'] })
|
||||
subscriptionType?: string
|
||||
}
|
||||
|
||||
export class SimInfoCollectionDTO {
|
||||
@ApiProperty({ type: Date, required: true })
|
||||
lastUpdated: Date
|
||||
|
||||
@ApiProperty({ type: [SimInfoDTO], required: true })
|
||||
sims: SimInfoDTO[]
|
||||
}
|
||||
|
||||
export class RegisterDeviceInputDTO {
|
||||
@ApiProperty({ type: Boolean })
|
||||
enabled?: boolean
|
||||
@@ -16,6 +56,9 @@ export class RegisterDeviceInputDTO {
|
||||
@ApiProperty({ type: String })
|
||||
model?: string
|
||||
|
||||
@ApiProperty({ type: String, required: false })
|
||||
name?: string
|
||||
|
||||
@ApiProperty({ type: String })
|
||||
serial?: string
|
||||
|
||||
@@ -33,6 +76,9 @@ export class RegisterDeviceInputDTO {
|
||||
|
||||
@ApiProperty({ type: String })
|
||||
appVersionCode?: number
|
||||
|
||||
@ApiProperty({ type: SimInfoCollectionDTO, required: false })
|
||||
simInfo?: SimInfoCollectionDTO
|
||||
}
|
||||
|
||||
export class SMSData {
|
||||
@@ -51,6 +97,21 @@ export class SMSData {
|
||||
})
|
||||
recipients: string[]
|
||||
|
||||
@ApiProperty({
|
||||
type: Number,
|
||||
required: false,
|
||||
description: 'Optional SIM subscription ID to use for sending SMS',
|
||||
})
|
||||
simSubscriptionId?: number
|
||||
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
required: false,
|
||||
description: 'Optional ISO 8601 date string to schedule SMS for future delivery (e.g., "2024-01-15T10:30:00Z"). Must be a future date.',
|
||||
example: '2024-01-15T10:30:00Z',
|
||||
})
|
||||
scheduledAt?: string
|
||||
|
||||
// TODO: restructure the Payload such that it contains bactchId, smsId, recipients and message in an optimized way
|
||||
// message: string
|
||||
// bactchId: string
|
||||
@@ -299,3 +360,144 @@ export class UpdateSMSStatusDTO {
|
||||
})
|
||||
errorMessage?: string
|
||||
}
|
||||
|
||||
export class HeartbeatInputDTO {
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
required: false,
|
||||
description: 'FCM token for push notifications',
|
||||
})
|
||||
fcmToken?: string
|
||||
|
||||
@ApiProperty({
|
||||
type: Number,
|
||||
required: false,
|
||||
description: 'Battery percentage (0-100)',
|
||||
})
|
||||
batteryPercentage?: number
|
||||
|
||||
@ApiProperty({
|
||||
type: Boolean,
|
||||
required: false,
|
||||
description: 'Whether device is currently charging',
|
||||
})
|
||||
isCharging?: boolean
|
||||
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
required: false,
|
||||
description: 'Network type',
|
||||
enum: ['wifi', 'cellular', 'none'],
|
||||
})
|
||||
networkType?: 'wifi' | 'cellular' | 'none'
|
||||
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
required: false,
|
||||
description: 'App version name',
|
||||
})
|
||||
appVersionName?: string
|
||||
|
||||
@ApiProperty({
|
||||
type: Number,
|
||||
required: false,
|
||||
description: 'App version code',
|
||||
})
|
||||
appVersionCode?: number
|
||||
|
||||
@ApiProperty({
|
||||
type: Number,
|
||||
required: false,
|
||||
description: 'Device uptime in milliseconds since boot',
|
||||
})
|
||||
deviceUptimeMillis?: number
|
||||
|
||||
@ApiProperty({
|
||||
type: Number,
|
||||
required: false,
|
||||
description: 'Free memory in bytes',
|
||||
})
|
||||
memoryFreeBytes?: number
|
||||
|
||||
@ApiProperty({
|
||||
type: Number,
|
||||
required: false,
|
||||
description: 'Total memory in bytes',
|
||||
})
|
||||
memoryTotalBytes?: number
|
||||
|
||||
@ApiProperty({
|
||||
type: Number,
|
||||
required: false,
|
||||
description: 'Max memory in bytes',
|
||||
})
|
||||
memoryMaxBytes?: number
|
||||
|
||||
@ApiProperty({
|
||||
type: Number,
|
||||
required: false,
|
||||
description: 'Available storage in bytes',
|
||||
})
|
||||
storageAvailableBytes?: number
|
||||
|
||||
@ApiProperty({
|
||||
type: Number,
|
||||
required: false,
|
||||
description: 'Total storage in bytes',
|
||||
})
|
||||
storageTotalBytes?: number
|
||||
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
required: false,
|
||||
description: 'Device timezone (e.g., "America/New_York")',
|
||||
})
|
||||
timezone?: string
|
||||
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
required: false,
|
||||
description: 'Device locale (e.g., "en_US")',
|
||||
})
|
||||
locale?: string
|
||||
|
||||
@ApiProperty({
|
||||
type: Boolean,
|
||||
required: false,
|
||||
description: 'Whether receive SMS feature is enabled',
|
||||
})
|
||||
receiveSMSEnabled?: boolean
|
||||
|
||||
@ApiProperty({ type: SimInfoCollectionDTO, required: false })
|
||||
simInfo?: SimInfoCollectionDTO
|
||||
}
|
||||
|
||||
export class HeartbeatResponseDTO {
|
||||
@ApiProperty({
|
||||
type: Boolean,
|
||||
required: true,
|
||||
description: 'Whether the heartbeat was successful',
|
||||
})
|
||||
success: boolean
|
||||
|
||||
@ApiProperty({
|
||||
type: Boolean,
|
||||
required: true,
|
||||
description: 'Whether the FCM token was updated',
|
||||
})
|
||||
fcmTokenUpdated: boolean
|
||||
|
||||
@ApiProperty({
|
||||
type: Date,
|
||||
required: true,
|
||||
description: 'Server timestamp of the heartbeat',
|
||||
})
|
||||
lastHeartbeat: Date
|
||||
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
required: false,
|
||||
description: 'Device name (if updated)',
|
||||
})
|
||||
name?: string
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ConfigModule } from '@nestjs/config'
|
||||
import { SmsQueueService } from './queue/sms-queue.service'
|
||||
import { SmsQueueProcessor } from './queue/sms-queue.processor'
|
||||
import { SmsStatusUpdateTask } from './tasks/sms-status-update.task'
|
||||
import { HeartbeatCheckTask } from './tasks/heartbeat-check.task'
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -50,7 +51,7 @@ import { SmsStatusUpdateTask } from './tasks/sms-status-update.task'
|
||||
ConfigModule,
|
||||
],
|
||||
controllers: [GatewayController],
|
||||
providers: [GatewayService, SmsQueueService, SmsQueueProcessor, SmsStatusUpdateTask],
|
||||
providers: [GatewayService, SmsQueueService, SmsQueueProcessor, SmsStatusUpdateTask, HeartbeatCheckTask],
|
||||
exports: [MongooseModule, GatewayService, SmsQueueService],
|
||||
})
|
||||
export class GatewayModule {}
|
||||
|
||||
@@ -50,6 +50,7 @@ describe('GatewayService', () => {
|
||||
const mockSmsModel = {
|
||||
create: jest.fn(),
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
updateMany: jest.fn(),
|
||||
countDocuments: jest.fn(),
|
||||
}
|
||||
@@ -179,7 +180,7 @@ describe('GatewayService', () => {
|
||||
})
|
||||
expect(service.updateDevice).toHaveBeenCalledWith(
|
||||
mockDevice._id.toString(),
|
||||
{ ...mockDeviceInput, enabled: true }
|
||||
{ ...mockDeviceInput, enabled: true, user: mockUser }
|
||||
)
|
||||
expect(result).toBeDefined()
|
||||
|
||||
@@ -538,6 +539,7 @@ describe('GatewayService', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
mockDeviceModel.findById.mockResolvedValue(mockDevice)
|
||||
mockSmsModel.findOne.mockResolvedValue(null)
|
||||
mockSmsModel.create.mockResolvedValue(mockSms)
|
||||
mockDeviceModel.findByIdAndUpdate.mockImplementation(() => ({
|
||||
exec: jest.fn().mockResolvedValue(true),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { HttpException, HttpStatus, Injectable } from '@nestjs/common'
|
||||
import { InjectModel } from '@nestjs/mongoose'
|
||||
import { Device, DeviceDocument } from './schemas/device.schema'
|
||||
import { Model } from 'mongoose'
|
||||
import { Model, Types } from 'mongoose'
|
||||
import * as firebaseAdmin from 'firebase-admin'
|
||||
import {
|
||||
ReceivedSMSDTO,
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
SendBulkSMSInputDTO,
|
||||
SendSMSInputDTO,
|
||||
UpdateSMSStatusDTO,
|
||||
HeartbeatInputDTO,
|
||||
HeartbeatResponseDTO,
|
||||
} from './gateway.dto'
|
||||
import { User } from '../users/schemas/user.schema'
|
||||
import { AuthService } from '../auth/auth.service'
|
||||
@@ -44,13 +46,28 @@ export class GatewayService {
|
||||
buildId: input.buildId,
|
||||
})
|
||||
|
||||
const deviceData: any = { ...input, user }
|
||||
|
||||
// Set default name to "brand model" if not provided
|
||||
if (!deviceData.name && input.brand && input.model) {
|
||||
deviceData.name = `${input.brand} ${input.model}`
|
||||
}
|
||||
|
||||
// Handle simInfo if provided
|
||||
if (input.simInfo) {
|
||||
deviceData.simInfo = {
|
||||
...input.simInfo,
|
||||
lastUpdated: input.simInfo.lastUpdated || new Date(),
|
||||
}
|
||||
}
|
||||
|
||||
if (device && device.appVersionCode <= 11) {
|
||||
return await this.updateDevice(device._id.toString(), {
|
||||
...input,
|
||||
...deviceData,
|
||||
enabled: true,
|
||||
})
|
||||
} else {
|
||||
return await this.deviceModel.create({ ...input, user })
|
||||
return await this.deviceModel.create(deviceData)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,10 +97,20 @@ export class GatewayService {
|
||||
if (input.enabled !== false) {
|
||||
input.enabled = true;
|
||||
}
|
||||
|
||||
const updateData: any = { ...input }
|
||||
|
||||
// Handle simInfo if provided
|
||||
if (input.simInfo) {
|
||||
updateData.simInfo = {
|
||||
...input.simInfo,
|
||||
lastUpdated: input.simInfo.lastUpdated || new Date(),
|
||||
}
|
||||
}
|
||||
|
||||
return await this.deviceModel.findByIdAndUpdate(
|
||||
deviceId,
|
||||
{ $set: input },
|
||||
{ $set: updateData },
|
||||
{ new: true },
|
||||
)
|
||||
}
|
||||
@@ -104,6 +131,55 @@ export class GatewayService {
|
||||
// return await this.deviceModel.findByIdAndDelete(deviceId)
|
||||
}
|
||||
|
||||
private calculateDelayFromScheduledAt(scheduledAt?: string): number | undefined {
|
||||
if (!scheduledAt) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
const scheduledDate = new Date(scheduledAt)
|
||||
|
||||
// Check if date is valid
|
||||
if (isNaN(scheduledDate.getTime())) {
|
||||
throw new HttpException(
|
||||
{
|
||||
success: false,
|
||||
error: 'Invalid scheduledAt format. Must be a valid ISO 8601 date string.',
|
||||
},
|
||||
HttpStatus.BAD_REQUEST,
|
||||
)
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const scheduledTime = scheduledDate.getTime()
|
||||
const delayMs = scheduledTime - now
|
||||
|
||||
// Reject past dates
|
||||
if (delayMs < 0) {
|
||||
throw new HttpException(
|
||||
{
|
||||
success: false,
|
||||
error: 'scheduledAt must be a future date',
|
||||
},
|
||||
HttpStatus.BAD_REQUEST,
|
||||
)
|
||||
}
|
||||
|
||||
return delayMs
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) {
|
||||
throw error
|
||||
}
|
||||
throw new HttpException(
|
||||
{
|
||||
success: false,
|
||||
error: 'Invalid scheduledAt format. Must be a valid ISO 8601 date string.',
|
||||
},
|
||||
HttpStatus.BAD_REQUEST,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async sendSMS(deviceId: string, smsData: SendSMSInputDTO): Promise<any> {
|
||||
const device = await this.deviceModel.findById(deviceId)
|
||||
|
||||
@@ -140,6 +216,20 @@ export class GatewayService {
|
||||
)
|
||||
}
|
||||
|
||||
// Calculate delay from scheduledAt if provided
|
||||
const delayMs = this.calculateDelayFromScheduledAt(smsData.scheduledAt)
|
||||
|
||||
// Validate that scheduling requires queue to be enabled
|
||||
if (delayMs !== undefined && !this.smsQueueService.isQueueEnabled()) {
|
||||
throw new HttpException(
|
||||
{
|
||||
success: false,
|
||||
error: 'SMS scheduling requires queue to be enabled',
|
||||
},
|
||||
HttpStatus.BAD_REQUEST,
|
||||
)
|
||||
}
|
||||
|
||||
await this.billingService.canPerformAction(
|
||||
device.user.toString(),
|
||||
'send_sms',
|
||||
@@ -171,7 +261,8 @@ export class GatewayService {
|
||||
|
||||
const fcmMessages: Message[] = []
|
||||
|
||||
for (const recipient of recipients) {
|
||||
for (let recipient of recipients) {
|
||||
recipient = recipient.replace(/\s+/g, "")
|
||||
const sms = await this.smsModel.create({
|
||||
device: device._id,
|
||||
smsBatch: smsBatch._id,
|
||||
@@ -180,12 +271,18 @@ export class GatewayService {
|
||||
recipient,
|
||||
requestedAt: new Date(),
|
||||
status: 'pending',
|
||||
...(smsData.simSubscriptionId !== undefined && {
|
||||
simSubscriptionId: smsData.simSubscriptionId,
|
||||
}),
|
||||
})
|
||||
const updatedSMSData = {
|
||||
smsId: sms._id,
|
||||
smsBatchId: smsBatch._id,
|
||||
message,
|
||||
recipients: [recipient],
|
||||
...(smsData.simSubscriptionId !== undefined && {
|
||||
simSubscriptionId: smsData.simSubscriptionId,
|
||||
}),
|
||||
|
||||
// Legacy fields to be removed in the future
|
||||
smsBody: message,
|
||||
@@ -218,6 +315,7 @@ export class GatewayService {
|
||||
deviceId,
|
||||
fcmMessages,
|
||||
smsBatch._id.toString(),
|
||||
delayMs,
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -338,6 +436,18 @@ export class GatewayService {
|
||||
body.messages.map((m) => m.recipients).flat().length,
|
||||
)
|
||||
|
||||
// Check if any message has scheduledAt and validate queue is enabled
|
||||
const hasScheduledMessages = body.messages.some((m) => m.scheduledAt)
|
||||
if (hasScheduledMessages && !this.smsQueueService.isQueueEnabled()) {
|
||||
throw new HttpException(
|
||||
{
|
||||
success: false,
|
||||
error: 'SMS scheduling requires queue to be enabled',
|
||||
},
|
||||
HttpStatus.BAD_REQUEST,
|
||||
)
|
||||
}
|
||||
|
||||
const { messageTemplate, messages } = body
|
||||
|
||||
const smsBatch = await this.smsBatchModel.create({
|
||||
@@ -352,7 +462,8 @@ export class GatewayService {
|
||||
status: 'pending',
|
||||
})
|
||||
|
||||
const fcmMessages: Message[] = []
|
||||
// Track FCM messages with their calculated delays for grouping
|
||||
const fcmMessagesWithDelays: Array<{ message: Message; delayMs?: number }> = []
|
||||
|
||||
for (const smsData of messages) {
|
||||
const message = smsData.message
|
||||
@@ -366,7 +477,11 @@ export class GatewayService {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const recipient of recipients) {
|
||||
// Calculate delay for this message's scheduledAt
|
||||
const delayMs = this.calculateDelayFromScheduledAt(smsData.scheduledAt)
|
||||
|
||||
for (let recipient of recipients) {
|
||||
recipient = recipient.replace(/\s+/g, "")
|
||||
const sms = await this.smsModel.create({
|
||||
device: device._id,
|
||||
smsBatch: smsBatch._id,
|
||||
@@ -375,12 +490,18 @@ export class GatewayService {
|
||||
recipient,
|
||||
requestedAt: new Date(),
|
||||
status: 'pending',
|
||||
...(smsData.simSubscriptionId !== undefined && {
|
||||
simSubscriptionId: smsData.simSubscriptionId,
|
||||
}),
|
||||
})
|
||||
const updatedSMSData = {
|
||||
smsId: sms._id,
|
||||
smsBatchId: smsBatch._id,
|
||||
message,
|
||||
recipients: [recipient],
|
||||
...(smsData.simSubscriptionId !== undefined && {
|
||||
simSubscriptionId: smsData.simSubscriptionId,
|
||||
}),
|
||||
|
||||
// Legacy fields to be removed in the future
|
||||
smsBody: message,
|
||||
@@ -397,19 +518,32 @@ export class GatewayService {
|
||||
priority: 'high',
|
||||
},
|
||||
}
|
||||
fcmMessages.push(fcmMessage)
|
||||
fcmMessagesWithDelays.push({ message: fcmMessage, delayMs })
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we should use the queue
|
||||
if (this.smsQueueService.isQueueEnabled()) {
|
||||
try {
|
||||
// Add to queue
|
||||
await this.smsQueueService.addSendSmsJob(
|
||||
deviceId,
|
||||
fcmMessages,
|
||||
smsBatch._id.toString(),
|
||||
)
|
||||
// Group messages by delay (undefined delay means immediate, group together)
|
||||
const messagesByDelay = new Map<number | undefined, Message[]>()
|
||||
for (const { message, delayMs } of fcmMessagesWithDelays) {
|
||||
const delayKey = delayMs !== undefined ? delayMs : undefined
|
||||
if (!messagesByDelay.has(delayKey)) {
|
||||
messagesByDelay.set(delayKey, [])
|
||||
}
|
||||
messagesByDelay.get(delayKey)!.push(message)
|
||||
}
|
||||
|
||||
// Queue each group with its respective delay
|
||||
for (const [delayMs, messages] of messagesByDelay.entries()) {
|
||||
await this.smsQueueService.addSendSmsJob(
|
||||
deviceId,
|
||||
messages,
|
||||
smsBatch._id.toString(),
|
||||
delayMs,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -424,7 +558,7 @@ export class GatewayService {
|
||||
status: 'failed',
|
||||
error: e.message,
|
||||
successCount: 0,
|
||||
failureCount: fcmMessages.length,
|
||||
failureCount: fcmMessagesWithDelays.length,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -445,6 +579,8 @@ export class GatewayService {
|
||||
}
|
||||
}
|
||||
|
||||
// For non-queue path, convert back to simple array
|
||||
const fcmMessages = fcmMessagesWithDelays.map(({ message }) => message)
|
||||
const fcmMessagesBatches = fcmMessages.map((m) => [m])
|
||||
const fcmResponses: BatchResponse[] = []
|
||||
|
||||
@@ -543,6 +679,29 @@ export class GatewayService {
|
||||
? new Date(dto.receivedAtInMillis)
|
||||
: dto.receivedAt
|
||||
|
||||
// Deduplication: Check for existing SMS with same device, sender, message, and receivedAt (within ±5 seconds tolerance)
|
||||
const toleranceMs = 5000 // 5 seconds
|
||||
const toleranceStart = new Date(receivedAt.getTime() - toleranceMs)
|
||||
const toleranceEnd = new Date(receivedAt.getTime() + toleranceMs)
|
||||
|
||||
const existingSMS = await this.smsModel.findOne({
|
||||
device: device._id,
|
||||
type: SMSType.RECEIVED,
|
||||
sender: dto.sender,
|
||||
message: dto.message,
|
||||
receivedAt: {
|
||||
$gte: toleranceStart,
|
||||
$lte: toleranceEnd,
|
||||
},
|
||||
})
|
||||
|
||||
if (existingSMS) {
|
||||
console.log(
|
||||
`Duplicate SMS detected for device ${deviceId}, sender ${dto.sender}, returning existing record: ${existingSMS._id}`,
|
||||
)
|
||||
return existingSMS
|
||||
}
|
||||
|
||||
const sms = await this.smsModel.create({
|
||||
device: device._id,
|
||||
message: dto.message,
|
||||
@@ -752,7 +911,11 @@ export class GatewayService {
|
||||
}
|
||||
|
||||
// Update the SMS
|
||||
await this.smsModel.findByIdAndUpdate(dto.smsId, { $set: updateData });
|
||||
const updatedSms = await this.smsModel.findByIdAndUpdate(
|
||||
dto.smsId,
|
||||
{ $set: updateData },
|
||||
{ new: true }
|
||||
);
|
||||
|
||||
// Check if all SMS in batch have the same status, then update batch status
|
||||
if (dto.smsBatchId) {
|
||||
@@ -774,10 +937,27 @@ export class GatewayService {
|
||||
|
||||
// Trigger webhook event for SMS status update
|
||||
try {
|
||||
let event: WebhookEvent
|
||||
switch (normalizedStatus) {
|
||||
case 'sent':
|
||||
event = WebhookEvent.MESSAGE_SENT
|
||||
break
|
||||
case 'delivered':
|
||||
event = WebhookEvent.MESSAGE_DELIVERED
|
||||
break
|
||||
case 'failed':
|
||||
event = WebhookEvent.MESSAGE_FAILED
|
||||
break
|
||||
case 'received':
|
||||
event = WebhookEvent.MESSAGE_RECEIVED
|
||||
break
|
||||
default:
|
||||
event = WebhookEvent.UNKNOWN_STATE
|
||||
}
|
||||
this.webhookService.deliverNotification({
|
||||
sms,
|
||||
sms: updatedSms,
|
||||
user: device.user,
|
||||
event: WebhookEvent.SMS_STATUS_UPDATED,
|
||||
event,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to trigger webhook event:', error);
|
||||
@@ -828,24 +1008,9 @@ export class GatewayService {
|
||||
}
|
||||
}
|
||||
|
||||
async getSMSById(deviceId: string, smsId: string): Promise<any> {
|
||||
// Check if device exists and is enabled
|
||||
const device = await this.deviceModel.findById(deviceId);
|
||||
if (!device) {
|
||||
throw new HttpException(
|
||||
{
|
||||
success: false,
|
||||
error: 'Device not found',
|
||||
},
|
||||
HttpStatus.NOT_FOUND,
|
||||
);
|
||||
}
|
||||
async getSMSById(smsId: string): Promise<any> {
|
||||
|
||||
// Find the SMS that belongs to this device
|
||||
const sms = await this.smsModel.findOne({
|
||||
_id: smsId,
|
||||
device: deviceId
|
||||
});
|
||||
const sms = await this.smsModel.findById(smsId);
|
||||
|
||||
if (!sms) {
|
||||
throw new HttpException(
|
||||
@@ -860,24 +1025,9 @@ export class GatewayService {
|
||||
return sms;
|
||||
}
|
||||
|
||||
async getSmsBatchById(deviceId: string, smsBatchId: string): Promise<any> {
|
||||
// Check if device exists
|
||||
const device = await this.deviceModel.findById(deviceId);
|
||||
if (!device) {
|
||||
throw new HttpException(
|
||||
{
|
||||
success: false,
|
||||
error: 'Device not found',
|
||||
},
|
||||
HttpStatus.NOT_FOUND,
|
||||
);
|
||||
}
|
||||
async getSmsBatchById(smsBatchId: string): Promise<any> {
|
||||
|
||||
// Find the SMS batch that belongs to this device
|
||||
const smsBatch = await this.smsBatchModel.findOne({
|
||||
_id: smsBatchId,
|
||||
device: deviceId
|
||||
});
|
||||
const smsBatch = await this.smsBatchModel.findById(smsBatchId);
|
||||
|
||||
if (!smsBatch) {
|
||||
throw new HttpException(
|
||||
@@ -891,8 +1041,8 @@ export class GatewayService {
|
||||
|
||||
// Find all SMS messages that belong to this batch
|
||||
const smsMessages = await this.smsModel.find({
|
||||
smsBatch: smsBatchId,
|
||||
device: deviceId
|
||||
smsBatch: new Types.ObjectId(smsBatchId),
|
||||
device: smsBatch.device
|
||||
});
|
||||
|
||||
// Return both the batch and its SMS messages
|
||||
@@ -901,4 +1051,110 @@ export class GatewayService {
|
||||
messages: smsMessages
|
||||
};
|
||||
}
|
||||
|
||||
async heartbeat(
|
||||
deviceId: string,
|
||||
input: HeartbeatInputDTO,
|
||||
): Promise<HeartbeatResponseDTO> {
|
||||
const device = await this.deviceModel.findById(deviceId)
|
||||
|
||||
if (!device) {
|
||||
throw new HttpException(
|
||||
{
|
||||
success: false,
|
||||
error: 'Device not found',
|
||||
},
|
||||
HttpStatus.NOT_FOUND,
|
||||
)
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const updateData: any = {
|
||||
lastHeartbeat: now,
|
||||
}
|
||||
|
||||
let fcmTokenUpdated = false
|
||||
|
||||
// Update FCM token if provided and different
|
||||
if (input.fcmToken && input.fcmToken !== device.fcmToken) {
|
||||
updateData.fcmToken = input.fcmToken
|
||||
fcmTokenUpdated = true
|
||||
}
|
||||
|
||||
// Update receiveSMSEnabled if provided and different
|
||||
if (
|
||||
input.receiveSMSEnabled !== undefined &&
|
||||
input.receiveSMSEnabled !== device.receiveSMSEnabled
|
||||
) {
|
||||
updateData.receiveSMSEnabled = input.receiveSMSEnabled
|
||||
}
|
||||
|
||||
// Update batteryInfo if provided
|
||||
if (input.batteryPercentage !== undefined || input.isCharging !== undefined) {
|
||||
if (input.batteryPercentage !== undefined) {
|
||||
updateData['batteryInfo.percentage'] = input.batteryPercentage
|
||||
}
|
||||
if (input.isCharging !== undefined) {
|
||||
updateData['batteryInfo.isCharging'] = input.isCharging
|
||||
}
|
||||
updateData['batteryInfo.lastUpdated'] = now
|
||||
}
|
||||
|
||||
// Update networkInfo if provided
|
||||
if (input.networkType !== undefined) {
|
||||
updateData['networkInfo.networkType'] = input.networkType
|
||||
updateData['networkInfo.lastUpdated'] = now
|
||||
}
|
||||
|
||||
// Update appVersionInfo if provided
|
||||
if (input.appVersionName !== undefined || input.appVersionCode !== undefined) {
|
||||
if (input.appVersionName !== undefined) {
|
||||
updateData['appVersionInfo.versionName'] = input.appVersionName
|
||||
}
|
||||
if (input.appVersionCode !== undefined) {
|
||||
updateData['appVersionInfo.versionCode'] = input.appVersionCode
|
||||
}
|
||||
updateData['appVersionInfo.lastUpdated'] = now
|
||||
}
|
||||
|
||||
// Update deviceUptimeInfo if provided
|
||||
if (input.deviceUptimeMillis !== undefined) {
|
||||
updateData['deviceUptimeInfo.uptimeMillis'] = input.deviceUptimeMillis
|
||||
updateData['deviceUptimeInfo.lastUpdated'] = now
|
||||
}
|
||||
|
||||
// Update systemInfo if timezone or locale provided
|
||||
if (input.timezone !== undefined || input.locale !== undefined) {
|
||||
if (input.timezone !== undefined) {
|
||||
updateData['systemInfo.timezone'] = input.timezone
|
||||
}
|
||||
if (input.locale !== undefined) {
|
||||
updateData['systemInfo.locale'] = input.locale
|
||||
}
|
||||
updateData['systemInfo.lastUpdated'] = now
|
||||
}
|
||||
|
||||
// Update simInfo if provided
|
||||
if (input.simInfo !== undefined) {
|
||||
updateData.simInfo = {
|
||||
...input.simInfo,
|
||||
lastUpdated: input.simInfo.lastUpdated || now,
|
||||
}
|
||||
}
|
||||
|
||||
// Update device with all changes
|
||||
await this.deviceModel.findByIdAndUpdate(deviceId, {
|
||||
$set: updateData,
|
||||
})
|
||||
|
||||
// Fetch updated device to get current name
|
||||
const updatedDevice = await this.deviceModel.findById(deviceId)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
fcmTokenUpdated,
|
||||
lastHeartbeat: now,
|
||||
name: updatedDevice?.name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ export class SmsQueueService {
|
||||
deviceId: string,
|
||||
fcmMessages: Message[],
|
||||
smsBatchId: string,
|
||||
delayMs?: number,
|
||||
) {
|
||||
this.logger.debug(`Adding send-sms job for batch ${smsBatchId}`)
|
||||
|
||||
@@ -41,8 +42,13 @@ export class SmsQueueService {
|
||||
batches.push(fcmMessages.slice(i, i + this.maxSmsBatchSize))
|
||||
}
|
||||
|
||||
// If delayMs is provided, use it for all batches (scheduled send)
|
||||
// Otherwise, use the existing delay multiplier logic
|
||||
const useScheduledDelay = delayMs !== undefined && delayMs >= 0
|
||||
|
||||
let delayMultiplier = 1;
|
||||
for (const batch of batches) {
|
||||
const delay = useScheduledDelay ? delayMs : 1000 * delayMultiplier++
|
||||
await this.smsQueue.add(
|
||||
'send-sms',
|
||||
{
|
||||
@@ -53,7 +59,7 @@ export class SmsQueueService {
|
||||
{
|
||||
priority: 1, // TODO: Make this dynamic based on users subscription plan
|
||||
attempts: 1,
|
||||
delay: 1000 * delayMultiplier++,
|
||||
delay: delay,
|
||||
backoff: {
|
||||
type: 'exponential',
|
||||
delay: 5000, // 5 seconds
|
||||
|
||||
@@ -9,7 +9,7 @@ export class Device {
|
||||
_id?: Types.ObjectId
|
||||
|
||||
@Prop({ type: Types.ObjectId, ref: User.name })
|
||||
user: User
|
||||
user: User | Types.ObjectId
|
||||
|
||||
@Prop({ type: Boolean, default: false })
|
||||
enabled: boolean
|
||||
@@ -26,6 +26,9 @@ export class Device {
|
||||
@Prop({ type: String })
|
||||
model: string
|
||||
|
||||
@Prop({ type: String, required: false })
|
||||
name?: string
|
||||
|
||||
@Prop({ type: String })
|
||||
serial: string
|
||||
|
||||
@@ -49,6 +52,142 @@ export class Device {
|
||||
|
||||
@Prop({ type: Number, default: 0 })
|
||||
receivedSMSCount: number
|
||||
|
||||
@Prop({ type: Boolean, default: true })
|
||||
heartbeatEnabled: boolean
|
||||
|
||||
@Prop({ type: Number, default: 30 })
|
||||
heartbeatIntervalMinutes: number
|
||||
|
||||
@Prop({ type: Boolean, default: false })
|
||||
receiveSMSEnabled: boolean
|
||||
|
||||
@Prop({ type: Date })
|
||||
lastHeartbeat: Date
|
||||
|
||||
@Prop({
|
||||
type: {
|
||||
percentage: Number,
|
||||
isCharging: Boolean,
|
||||
lastUpdated: Date,
|
||||
},
|
||||
})
|
||||
batteryInfo: {
|
||||
percentage?: number
|
||||
isCharging?: boolean
|
||||
lastUpdated?: Date
|
||||
}
|
||||
|
||||
@Prop({
|
||||
type: {
|
||||
networkType: String,
|
||||
lastUpdated: Date,
|
||||
},
|
||||
})
|
||||
networkInfo: {
|
||||
networkType?: 'wifi' | 'cellular' | 'none'
|
||||
lastUpdated?: Date
|
||||
}
|
||||
|
||||
@Prop({
|
||||
type: {
|
||||
versionName: String,
|
||||
versionCode: Number,
|
||||
lastUpdated: Date,
|
||||
},
|
||||
})
|
||||
appVersionInfo: {
|
||||
versionName?: string
|
||||
versionCode?: number
|
||||
lastUpdated?: Date
|
||||
}
|
||||
|
||||
@Prop({
|
||||
type: {
|
||||
uptimeMillis: Number,
|
||||
lastUpdated: Date,
|
||||
},
|
||||
})
|
||||
deviceUptimeInfo: {
|
||||
uptimeMillis?: number
|
||||
lastUpdated?: Date
|
||||
}
|
||||
|
||||
@Prop({
|
||||
type: {
|
||||
freeBytes: Number,
|
||||
totalBytes: Number,
|
||||
maxBytes: Number,
|
||||
lastUpdated: Date,
|
||||
},
|
||||
})
|
||||
memoryInfo: {
|
||||
freeBytes?: number
|
||||
totalBytes?: number
|
||||
maxBytes?: number
|
||||
lastUpdated?: Date
|
||||
}
|
||||
|
||||
@Prop({
|
||||
type: {
|
||||
availableBytes: Number,
|
||||
totalBytes: Number,
|
||||
lastUpdated: Date,
|
||||
},
|
||||
})
|
||||
storageInfo: {
|
||||
availableBytes?: number
|
||||
totalBytes?: number
|
||||
lastUpdated?: Date
|
||||
}
|
||||
|
||||
@Prop({
|
||||
type: {
|
||||
timezone: String,
|
||||
locale: String,
|
||||
lastUpdated: Date,
|
||||
},
|
||||
})
|
||||
systemInfo: {
|
||||
timezone?: string
|
||||
locale?: string
|
||||
lastUpdated?: Date
|
||||
}
|
||||
|
||||
@Prop({
|
||||
type: {
|
||||
lastUpdated: Date,
|
||||
sims: [
|
||||
{
|
||||
subscriptionId: Number,
|
||||
iccId: String,
|
||||
cardId: Number,
|
||||
carrierName: String,
|
||||
displayName: String,
|
||||
simSlotIndex: Number,
|
||||
mcc: String,
|
||||
mnc: String,
|
||||
countryIso: String,
|
||||
subscriptionType: String,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
simInfo: {
|
||||
lastUpdated?: Date
|
||||
sims?: Array<{
|
||||
subscriptionId: number
|
||||
iccId?: string
|
||||
cardId?: number
|
||||
carrierName?: string
|
||||
displayName?: string
|
||||
simSlotIndex?: number
|
||||
mcc?: string
|
||||
mnc?: string
|
||||
countryIso?: string
|
||||
subscriptionType?: string
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
export const DeviceSchema = SchemaFactory.createForClass(Device)
|
||||
|
||||
@@ -10,10 +10,10 @@ export class SMS {
|
||||
_id?: Types.ObjectId
|
||||
|
||||
@Prop({ type: Types.ObjectId, ref: Device.name, required: true })
|
||||
device: Device
|
||||
device: Device | Types.ObjectId
|
||||
|
||||
@Prop({ type: Types.ObjectId, ref: SMSBatch.name })
|
||||
smsBatch: SMSBatch
|
||||
smsBatch: SMSBatch | Types.ObjectId
|
||||
|
||||
@Prop({ type: String })
|
||||
message: string
|
||||
@@ -62,6 +62,9 @@ export class SMS {
|
||||
@Prop({ type: String, default: 'pending' })
|
||||
status: 'pending' | 'sent' | 'delivered' | 'failed' | 'unknown' | 'received'
|
||||
|
||||
@Prop({ type: Number, required: false })
|
||||
simSubscriptionId?: number
|
||||
|
||||
// misc metadata for debugging
|
||||
@Prop({ type: Object })
|
||||
metadata: Record<string, any>
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Injectable, Logger } from '@nestjs/common'
|
||||
import { Cron, CronExpression } from '@nestjs/schedule'
|
||||
import { InjectModel } from '@nestjs/mongoose'
|
||||
import { Model } from 'mongoose'
|
||||
import { Device, DeviceDocument } from '../schemas/device.schema'
|
||||
import * as firebaseAdmin from 'firebase-admin'
|
||||
import { Message } from 'firebase-admin/messaging'
|
||||
|
||||
@Injectable()
|
||||
export class HeartbeatCheckTask {
|
||||
private readonly logger = new Logger(HeartbeatCheckTask.name)
|
||||
|
||||
constructor(
|
||||
@InjectModel(Device.name) private deviceModel: Model<DeviceDocument>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Cron job that runs every 5 minutes to check for devices with stale heartbeats
|
||||
* (>30 minutes) and send FCM push notifications to trigger heartbeat requests.
|
||||
*/
|
||||
@Cron(CronExpression.EVERY_5_MINUTES)
|
||||
async checkAndTriggerStaleHeartbeats() {
|
||||
this.logger.log('Running cron job to check for stale heartbeats')
|
||||
|
||||
const thirtyMinutesAgo = new Date(Date.now() - 30 * 60 * 1000)
|
||||
|
||||
try {
|
||||
// Find devices with stale heartbeats
|
||||
const devices = await this.deviceModel.find({
|
||||
heartbeatEnabled: true,
|
||||
enabled: true,
|
||||
$or: [
|
||||
{ lastHeartbeat: null },
|
||||
{ lastHeartbeat: { $lt: thirtyMinutesAgo } },
|
||||
],
|
||||
fcmToken: { $exists: true, $ne: null },
|
||||
})
|
||||
|
||||
if (devices.length === 0) {
|
||||
this.logger.log('No devices with stale heartbeats found')
|
||||
return
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Found ${devices.length} device(s) with stale heartbeats, sending FCM notifications`,
|
||||
)
|
||||
|
||||
// Send FCM messages to trigger heartbeats
|
||||
const fcmMessages: Message[] = []
|
||||
const deviceIds: string[] = []
|
||||
|
||||
for (const device of devices) {
|
||||
if (!device.fcmToken) {
|
||||
continue
|
||||
}
|
||||
|
||||
const fcmMessage: Message = {
|
||||
data: {
|
||||
type: 'heartbeat_check',
|
||||
},
|
||||
token: device.fcmToken,
|
||||
android: {
|
||||
priority: 'high',
|
||||
},
|
||||
}
|
||||
|
||||
fcmMessages.push(fcmMessage)
|
||||
deviceIds.push(device._id.toString())
|
||||
}
|
||||
|
||||
if (fcmMessages.length === 0) {
|
||||
this.logger.warn('No valid FCM tokens found for devices with stale heartbeats')
|
||||
return
|
||||
}
|
||||
|
||||
// Send FCM messages
|
||||
const response = await firebaseAdmin.messaging().sendEach(fcmMessages)
|
||||
|
||||
this.logger.log(
|
||||
`Sent ${response.successCount} heartbeat check FCM notification(s), ${response.failureCount} failed`,
|
||||
)
|
||||
|
||||
// Log failures for debugging
|
||||
if (response.failureCount > 0) {
|
||||
response.responses.forEach((resp, index) => {
|
||||
if (!resp.success) {
|
||||
this.logger.error(
|
||||
`Failed to send heartbeat check to device ${deviceIds[index]}: ${resp.error?.message || 'Unknown error'}`,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error('Error checking and triggering stale heartbeats', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
<html lang='en'>
|
||||
<head>
|
||||
<meta charset='utf-8' />
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1' />
|
||||
<title>Complete Your TextBee Upgrade</title>
|
||||
<style>
|
||||
.preheader { display:none !important; visibility:hidden; opacity:0;
|
||||
color:transparent; height:0; width:0; overflow:hidden; mso-hide:all; }
|
||||
@media screen and (max-width: 600px) { .container { width:100% !important;
|
||||
} .stack { display:block !important; width:100% !important; } .p-sm {
|
||||
padding:16px !important; } .text-center-sm { text-align:center !important;
|
||||
} }
|
||||
</style>
|
||||
</head>
|
||||
<body style='margin:0; padding:0; background:#f7f9fc;'>
|
||||
<div class='preheader'>Your textbee pro upgrade is waiting - complete your
|
||||
payment in just few clicks.</div>
|
||||
|
||||
<table
|
||||
role='presentation'
|
||||
cellpadding='0'
|
||||
cellspacing='0'
|
||||
border='0'
|
||||
width='100%'
|
||||
style='background:#f7f9fc;'
|
||||
>
|
||||
<tr>
|
||||
<td align='center' style='padding:24px;'>
|
||||
<table
|
||||
role='presentation'
|
||||
cellpadding='0'
|
||||
cellspacing='0'
|
||||
border='0'
|
||||
width='600'
|
||||
class='container'
|
||||
style='width:600px; max-width:600px;'
|
||||
>
|
||||
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td style='padding:12px 16px 0 16px;'>
|
||||
<table
|
||||
role='presentation'
|
||||
width='100%'
|
||||
cellspacing='0'
|
||||
cellpadding='0'
|
||||
border='0'
|
||||
>
|
||||
<tr>
|
||||
<td class='stack' valign='middle' style='padding:8px 0;'>
|
||||
<table
|
||||
role='presentation'
|
||||
cellspacing='0'
|
||||
cellpadding='0'
|
||||
border='0'
|
||||
>
|
||||
<tr>
|
||||
<td valign='middle' style='padding-right:10px;'>
|
||||
<img
|
||||
src='https://textbee.dev/images/logo.png'
|
||||
alt='TextBee'
|
||||
width='36'
|
||||
height='36'
|
||||
style='display:block; border:0; outline:none; text-decoration:none;'
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
valign='middle'
|
||||
style='font:600 18px Arial, Helvetica, sans-serif; color:#EA580C;'
|
||||
>textbee.dev</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
<td
|
||||
class='stack text-center-sm'
|
||||
valign='middle'
|
||||
align='right'
|
||||
style='padding:8px 0;'
|
||||
>
|
||||
<a
|
||||
href='https://textbee.dev/x'
|
||||
style='margin-left:10px; display:inline-block;'
|
||||
>
|
||||
<img
|
||||
src='https://textbee.dev/images/socials/twitter.png'
|
||||
alt='X'
|
||||
width='20'
|
||||
height='20'
|
||||
style='display:block; border:0;'
|
||||
/>
|
||||
</a>
|
||||
<a
|
||||
href='https://textbee.dev/linkedin'
|
||||
style='margin-left:10px; display:inline-block;'
|
||||
>
|
||||
<img
|
||||
src='https://textbee.dev/images/socials/linkedin.png'
|
||||
alt='LinkedIn'
|
||||
width='20'
|
||||
height='20'
|
||||
style='display:block; border:0;'
|
||||
/>
|
||||
</a>
|
||||
<a
|
||||
href='https://textbee.dev/github'
|
||||
style='margin-left:10px; display:inline-block;'
|
||||
>
|
||||
<img
|
||||
src='https://textbee.dev/images/socials/github.png'
|
||||
alt='GitHub'
|
||||
width='20'
|
||||
height='20'
|
||||
style='display:block; border:0;'
|
||||
/>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Main Content -->
|
||||
<tr>
|
||||
<td align='center' style='padding:16px 16px 0 16px;'>
|
||||
<table
|
||||
role='presentation'
|
||||
width='100%'
|
||||
cellspacing='0'
|
||||
cellpadding='0'
|
||||
border='0'
|
||||
style='background:#ffffff; border-radius:10px;'
|
||||
>
|
||||
|
||||
<!-- Hero -->
|
||||
<tr>
|
||||
<td
|
||||
align='center'
|
||||
style='background:#F97316; border-radius:10px 10px 0 0; padding:28px 20px;'
|
||||
>
|
||||
<div
|
||||
style='font:700 24px Arial, Helvetica, sans-serif; color:#ffffff;'
|
||||
>Don't Miss Out!</div>
|
||||
<div
|
||||
style='font:400 14px Arial, Helvetica, sans-serif; color:#fff; opacity:0.95; padding-top:6px;'
|
||||
>Your textbee pro upgrade is waiting</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Body -->
|
||||
<tr>
|
||||
<td
|
||||
class='p-sm'
|
||||
style='padding:28px; font:16px/1.6 Arial, Helvetica, sans-serif; color:#111827;'
|
||||
>
|
||||
|
||||
<div
|
||||
style='font:700 18px Arial, Helvetica, sans-serif; color:#111827; margin-bottom:8px;'
|
||||
>Hi {{name}},</div>
|
||||
|
||||
<p style='margin:0 0 16px 0;'>We noticed you started
|
||||
upgrading to textbee pro but didn't complete your
|
||||
purchase. Your upgrade is still available and ready to
|
||||
unlock powerful features for your SMS workflow.</p>
|
||||
|
||||
<!-- Urgency Box -->
|
||||
<table
|
||||
role='presentation'
|
||||
cellpadding='0'
|
||||
cellspacing='0'
|
||||
border='0'
|
||||
width='100%'
|
||||
style='margin:18px 0;'
|
||||
>
|
||||
<tr>
|
||||
<td
|
||||
style='padding:16px; background:#fef3c7; border-left:4px solid #f59e0b; border-radius:6px;'
|
||||
>
|
||||
<div
|
||||
style='font:600 15px Arial, Helvetica, sans-serif; color:#92400e; margin-bottom:4px;'
|
||||
>A 30% discount is waiting for you</div>
|
||||
<div
|
||||
style='font:14px/1.5 Arial, Helvetica, sans-serif; color:#78350f;'
|
||||
>Complete your upgrade now and save on your textbee
|
||||
pro subscription.</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Primary CTA -->
|
||||
<div style='text-align:center; padding:8px 0 20px;'>
|
||||
<a
|
||||
href='{{checkoutUrl}}'
|
||||
style='background:#F97316; border:1px solid #EA580C; border-radius:6px; color:#ffffff; display:inline-block; font:700 16px Arial, Helvetica, sans-serif; line-height:48px; text-align:center; text-decoration:none; width:280px;'
|
||||
>Complete Your Upgrade</a>
|
||||
</div>
|
||||
|
||||
<!-- Benefits Reminder -->
|
||||
<div
|
||||
style='font:700 16px Arial, Helvetica, sans-serif; margin:20px 0 8px;'
|
||||
>What you'll get with Pro:</div>
|
||||
<ul
|
||||
style='padding-left:18px; margin:8px 0 20px; font:15px/1.6 Arial, Helvetica, sans-serif; color:#374151;'
|
||||
>
|
||||
<li style='margin:6px 0;'>5,000 SMS messages (send &
|
||||
receive) per month</li>
|
||||
<li style='margin:6px 0;'>Connect up to 5 devices</li>
|
||||
<li style='margin:6px 0;'>Higher rate limits for bulk
|
||||
messaging</li>
|
||||
<li style='margin:6px 0;'>Advanced analytics & reporting
|
||||
(launching soon)</li>
|
||||
<li style='margin:6px 0;'>Priority support and more</li>
|
||||
</ul>
|
||||
|
||||
<!-- Secondary CTA -->
|
||||
<div style='text-align:center; margin:16px 0;'>
|
||||
<a
|
||||
href='https://textbee.dev/#pricing'
|
||||
style='font:600 14px Arial, Helvetica, sans-serif; color:#EA580C; text-decoration:underline;'
|
||||
>View pricing details →</a>
|
||||
</div>
|
||||
|
||||
<!-- Support -->
|
||||
<table
|
||||
role='presentation'
|
||||
cellpadding='0'
|
||||
cellspacing='0'
|
||||
border='0'
|
||||
width='100%'
|
||||
style='margin:20px 0; padding:16px; background:#f8f9fa; border-radius:8px;'
|
||||
>
|
||||
<tr>
|
||||
<td
|
||||
style='font:14px/1.5 Arial, Helvetica, sans-serif; color:#6b7280; text-align:center;'
|
||||
>
|
||||
Questions? Reply to this email or contact us at
|
||||
<a
|
||||
href='mailto:support@textbee.dev'
|
||||
style='color:#EA580C; text-decoration:underline;'
|
||||
>support@textbee.dev</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td
|
||||
align='center'
|
||||
style='padding:16px; font:12px/1.6 Arial, Helvetica, sans-serif; color:#6b7280;'
|
||||
>
|
||||
<div>© 2025 textbee.dev. All rights reserved.</div>
|
||||
<div style='margin-top:4px;'>
|
||||
<a
|
||||
href='https://app.textbee.dev/dashboard/account/'
|
||||
style='color:#6b7280; text-decoration:underline;'
|
||||
>Manage notifications</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,87 @@
|
||||
<html lang='en' xmlns:v='urn:schemas-microsoft-com:vml' xmlns:o='urn:schemas-microsoft-com:office:office'>
|
||||
<head>
|
||||
<meta charset='utf-8' />
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1' />
|
||||
<meta http-equiv='x-ua-compatible' content='ie=edge' />
|
||||
<title>{{title}} • {{brandName}}</title>
|
||||
<style>
|
||||
.preheader { display:none !important; visibility:hidden; opacity:0; color:transparent; height:0; width:0; overflow:hidden; mso-hide:all; }
|
||||
@media screen and (max-width: 600px) { .container { width:100% !important; } .stack { display:block !important; width:100% !important; } .p-sm { padding:16px !important; } .text-center-sm { text-align:center !important; } .hide-sm { display:none !important; } }
|
||||
</style>
|
||||
<!--[if mso]>
|
||||
<style type="text/css"> body, table, td, a { font-family: Helvetica, Arial, sans-serif !important; } </style>
|
||||
<![endif]-->
|
||||
</head>
|
||||
<body style='margin:0; padding:0; background:#f7f9fc;'>
|
||||
<div class='preheader'>Important account update from {{brandName}}</div>
|
||||
<table role='presentation' cellpadding='0' cellspacing='0' border='0' width='100%' class='email-bg' style='background:#f7f9fc;'>
|
||||
<tr>
|
||||
<td align='center' style='padding:24px;'>
|
||||
<table role='presentation' cellpadding='0' cellspacing='0' border='0' width='600' class='container' style='width:600px; max-width:600px;'>
|
||||
<tr>
|
||||
<td style='padding:12px 16px 0 16px;'>
|
||||
<table role='presentation' width='100%' cellspacing='0' cellpadding='0' border='0'>
|
||||
<tr>
|
||||
<td class='stack' valign='middle' style='padding:8px 0;'>
|
||||
<table role='presentation' cellspacing='0' cellpadding='0' border='0'>
|
||||
<tr>
|
||||
<td valign='middle' style='padding-right:10px;'>
|
||||
<img src='https://textbee.dev/images/logo.png' alt='{{brandName}}' width='36' height='36' style='display:block; border:0; outline:none; text-decoration:none;' />
|
||||
</td>
|
||||
<td valign='middle' style='font:600 18px Arial, Helvetica, sans-serif; color:#EA580C;'>{{brandName}}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
<td class='stack text-center-sm' valign='middle' align='right' style='padding:8px 0;'>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align='center' style='padding:16px 16px 0 16px;'>
|
||||
<table role='presentation' width='100%' cellspacing='0' cellpadding='0' border='0' class='card' style='background:#ffffff; border-radius:10px;'>
|
||||
<tr>
|
||||
<td align='center' style='background:#F97316; border-radius:10px 10px 0 0; padding:28px 20px;'>
|
||||
<div style='font:700 24px Arial, Helvetica, sans-serif; color:#ffffff;'>{{title}}</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class='p-sm' style='padding:28px; font:16px/1.6 Arial, Helvetica, sans-serif; color:#111827;'>
|
||||
<div style='font:700 18px Arial, Helvetica, sans-serif; color:#111827; margin-bottom:8px;'>Hi {{name}},</div>
|
||||
<p style='margin:0 0 16px 0;'>{{message}}</p>
|
||||
<div style='text-align:center; padding:8px 0 2px;'>
|
||||
<!--[if mso]>
|
||||
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="{{ctaUrl}}" style="height:48px;v-text-anchor:middle;width:260px;" arcsize="10%" strokecolor="#EA580C" fillcolor="#F97316">
|
||||
<w:anchorlock/>
|
||||
<center style="color:#ffffff;font-family:Arial, Helvetica, sans-serif;font-size:16px;font-weight:bold;">{{ctaLabel}}</center>
|
||||
</v:roundrect>
|
||||
<![endif]-->
|
||||
<!--[if !mso]><!-- -->
|
||||
<a href='{{ctaUrl}}' style='background:#F97316; border:1px solid #EA580C; border-radius:6px; color:#ffffff; display:inline-block; font:700 16px Arial, Helvetica, sans-serif; line-height:48px; text-align:center; text-decoration:none; width:260px;'>{{ctaLabel}}</a>
|
||||
<!--<![endif]-->
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align='center' style='padding:16px;'>
|
||||
<table role='presentation' width='100%' cellspacing='0' cellpadding='0' border='0'>
|
||||
<tr>
|
||||
<td align='center' style='font:12px/1.6 Arial, Helvetica, sans-serif; color:#6b7280;'>
|
||||
<div>© 2025 {{brandName}}. All rights reserved.</div>
|
||||
<div class='muted' style='margin-top:4px;'>Manage notifications in <a href='https://app.textbee.dev/dashboard/account/' style='color:#6b7280; text-decoration:underline;'>Account settings</a>.</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+20
-1
@@ -1,5 +1,6 @@
|
||||
import 'dotenv/config'
|
||||
import { VersioningType } from '@nestjs/common'
|
||||
import * as crypto from 'crypto'
|
||||
import { VersioningType, Logger } from '@nestjs/common'
|
||||
import { NestFactory } from '@nestjs/core'
|
||||
import { AppModule } from './app.module'
|
||||
import * as firebase from 'firebase-admin'
|
||||
@@ -7,6 +8,24 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'
|
||||
import * as express from 'express'
|
||||
import { NestExpressApplication } from '@nestjs/platform-express'
|
||||
|
||||
// Ensure crypto is available globally for @nestjs/schedule
|
||||
if (typeof globalThis.crypto === 'undefined') {
|
||||
globalThis.crypto = crypto as any
|
||||
}
|
||||
|
||||
// Global error handlers to prevent server crashes
|
||||
const logger = new Logger('GlobalErrorHandler')
|
||||
|
||||
process.on('uncaughtException', (error: Error) => {
|
||||
logger.error('Uncaught Exception:', error.stack || error.message)
|
||||
// Don't exit the process for uncaught exceptions in production
|
||||
// process.exit(1)
|
||||
})
|
||||
|
||||
process.on('unhandledRejection', (reason: any, promise: Promise<any>) => {
|
||||
logger.error('Unhandled Rejection at:', promise, 'reason:', reason)
|
||||
})
|
||||
|
||||
async function bootstrap() {
|
||||
const app: NestExpressApplication = await NestFactory.create(AppModule)
|
||||
const PORT = process.env.PORT || 3001
|
||||
|
||||
@@ -39,6 +39,10 @@ export class CreateSupportMessageDto {
|
||||
@IsString()
|
||||
message: string
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
turnstileToken: string
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ip?: string
|
||||
|
||||
@@ -7,7 +7,7 @@ export type SupportMessageDocument = SupportMessage & Document
|
||||
@Schema({ timestamps: true })
|
||||
export class SupportMessage {
|
||||
@Prop({ type: Types.ObjectId, ref: User.name })
|
||||
user: User
|
||||
user: User | Types.ObjectId
|
||||
|
||||
@Prop()
|
||||
name: string
|
||||
|
||||
@@ -7,10 +7,14 @@ import { SupportService } from './support.service'
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
|
||||
import { Request } from 'express'
|
||||
import { OptionalAuthGuard } from '../auth/guards/optional-auth.guard'
|
||||
import { TurnstileService } from '../common/turnstile.service'
|
||||
|
||||
@Controller('support')
|
||||
export class SupportController {
|
||||
constructor(private readonly supportService: SupportService) {}
|
||||
constructor(
|
||||
private readonly supportService: SupportService,
|
||||
private readonly turnstileService: TurnstileService,
|
||||
) {}
|
||||
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
@Post('customer-support')
|
||||
@@ -18,6 +22,8 @@ export class SupportController {
|
||||
@Body() createSupportMessageDto: CreateSupportMessageDto,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
await this.turnstileService.verify(createSupportMessageDto.turnstileToken)
|
||||
|
||||
const ip = req.ip || (req.headers['x-forwarded-for'] as string)
|
||||
const userAgent = req.headers['user-agent'] as string
|
||||
|
||||
@@ -36,9 +42,11 @@ export class SupportController {
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('request-account-deletion')
|
||||
async requestAccountDeletion(
|
||||
@Body() body: { message: string },
|
||||
@Body() body: { message: string; turnstileToken: string },
|
||||
@Req() req: Request,
|
||||
) {
|
||||
await this.turnstileService.verify(body.turnstileToken)
|
||||
|
||||
const ip = req.ip || (req.headers['x-forwarded-for'] as string)
|
||||
const userAgent = req.headers['user-agent'] as string
|
||||
const user = req.user
|
||||
@@ -51,6 +59,7 @@ export class SupportController {
|
||||
message: body.message,
|
||||
ip,
|
||||
userAgent,
|
||||
turnstileToken: body.turnstileToken,
|
||||
}
|
||||
|
||||
return this.supportService.requestAccountDeletion(createSupportMessageDto)
|
||||
|
||||
@@ -10,15 +10,19 @@ import { UsersModule } from 'src/users/users.module'
|
||||
import { BillingModule } from 'src/billing/billing.module'
|
||||
import { MailModule } from 'src/mail/mail.module'
|
||||
import { AuthModule } from 'src/auth/auth.module'
|
||||
import { Subscription, SubscriptionSchema } from 'src/billing/schemas/subscription.schema'
|
||||
import { CommonModule } from '../common/common.module'
|
||||
@Module({
|
||||
imports: [
|
||||
MongooseModule.forFeature([
|
||||
{ name: SupportMessage.name, schema: SupportMessageSchema },
|
||||
{ name: Subscription.name, schema: SubscriptionSchema },
|
||||
]),
|
||||
UsersModule,
|
||||
BillingModule,
|
||||
MailModule,
|
||||
AuthModule,
|
||||
CommonModule,
|
||||
],
|
||||
controllers: [SupportController],
|
||||
providers: [SupportService],
|
||||
|
||||
@@ -2,6 +2,8 @@ import {
|
||||
Injectable,
|
||||
ConflictException,
|
||||
NotFoundException,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common'
|
||||
import { InjectModel } from '@nestjs/mongoose'
|
||||
import { isValidObjectId, Model, Types } from 'mongoose'
|
||||
@@ -28,20 +30,33 @@ export class SupportService {
|
||||
async createSupportMessage(
|
||||
createSupportMessageDto: CreateSupportMessageDto,
|
||||
): Promise<{ message: string }> {
|
||||
const { turnstileToken, ...sanitizedDto } = createSupportMessageDto
|
||||
try {
|
||||
// Check rate limit: max 3 requests per 24 hours
|
||||
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000)
|
||||
const recentRequestsCount = await this.supportMessageModel.countDocuments({
|
||||
email: sanitizedDto.email,
|
||||
createdAt: { $gte: twentyFourHoursAgo },
|
||||
})
|
||||
|
||||
if (recentRequestsCount >= 3) {
|
||||
throw new HttpException(
|
||||
'Too many support requests. Please try again later.',
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
)
|
||||
}
|
||||
|
||||
// Create and save the support message
|
||||
const createdMessage = new this.supportMessageModel(
|
||||
createSupportMessageDto,
|
||||
)
|
||||
const createdMessage = new this.supportMessageModel(sanitizedDto)
|
||||
const savedMessage = await createdMessage.save()
|
||||
|
||||
// Determine if the user is registered
|
||||
let user = null
|
||||
if (
|
||||
createSupportMessageDto.user &&
|
||||
isValidObjectId(createSupportMessageDto.user)
|
||||
sanitizedDto.user &&
|
||||
isValidObjectId(sanitizedDto.user)
|
||||
) {
|
||||
user = await this.userModel.findById(createSupportMessageDto.user)
|
||||
user = await this.userModel.findById(sanitizedDto.user)
|
||||
}
|
||||
|
||||
// Send confirmation email to user
|
||||
@@ -53,10 +68,10 @@ export class SupportService {
|
||||
template: 'customer-support-confirmation',
|
||||
context: {
|
||||
name: createSupportMessageDto.name,
|
||||
email: createSupportMessageDto.email,
|
||||
phone: createSupportMessageDto.phone || 'Not provided',
|
||||
category: createSupportMessageDto.category,
|
||||
message: createSupportMessageDto.message,
|
||||
email: sanitizedDto.email,
|
||||
phone: sanitizedDto.phone || 'Not provided',
|
||||
category: sanitizedDto.category,
|
||||
message: sanitizedDto.message,
|
||||
appLogoUrl:
|
||||
process.env.APP_LOGO_URL || 'https://textbee.dev/logo.png',
|
||||
currentYear: new Date().getFullYear(),
|
||||
@@ -74,16 +89,17 @@ export class SupportService {
|
||||
async requestAccountDeletion(
|
||||
createSupportMessageDto: CreateSupportMessageDto,
|
||||
): Promise<{ message: string }> {
|
||||
const { turnstileToken, ...sanitizedDto } = createSupportMessageDto
|
||||
try {
|
||||
// Check if user exists
|
||||
if (
|
||||
!createSupportMessageDto.user ||
|
||||
!isValidObjectId(createSupportMessageDto.user)
|
||||
!sanitizedDto.user ||
|
||||
!isValidObjectId(sanitizedDto.user)
|
||||
) {
|
||||
throw new NotFoundException('User not found')
|
||||
}
|
||||
|
||||
const userId = new Types.ObjectId(createSupportMessageDto.user.toString())
|
||||
const userId = new Types.ObjectId(sanitizedDto.user.toString())
|
||||
const user = await this.userModel.findById(userId)
|
||||
|
||||
if (!user) {
|
||||
@@ -104,7 +120,7 @@ export class SupportService {
|
||||
|
||||
// Create and save the support message
|
||||
const createdMessage = new this.supportMessageModel(
|
||||
createSupportMessageDto,
|
||||
sanitizedDto,
|
||||
)
|
||||
const savedMessage = await createdMessage.save()
|
||||
|
||||
@@ -113,7 +129,7 @@ export class SupportService {
|
||||
{ _id: userId },
|
||||
{
|
||||
accountDeletionRequestedAt: new Date(),
|
||||
accountDeletionReason: createSupportMessageDto.message || null,
|
||||
accountDeletionReason: sanitizedDto.message || null,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -127,7 +143,7 @@ export class SupportService {
|
||||
context: {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
message: createSupportMessageDto.message || 'No reason provided',
|
||||
message: sanitizedDto.message || 'No reason provided',
|
||||
appLogoUrl:
|
||||
process.env.APP_LOGO_URL || 'https://textbee.dev/logo.png',
|
||||
currentYear: new Date().getFullYear(),
|
||||
|
||||
@@ -43,6 +43,9 @@ export class User {
|
||||
|
||||
@Prop({ type: String })
|
||||
accountDeletionReason: string
|
||||
|
||||
@Prop({ type: Object })
|
||||
meta: Object
|
||||
}
|
||||
|
||||
export const UserSchema = SchemaFactory.createForClass(User)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Cron, CronExpression } from '@nestjs/schedule'
|
||||
import { MailService } from '../mail/mail.service'
|
||||
import { BillingService } from '../billing/billing.service'
|
||||
import { Device, DeviceDocument } from '../gateway/schemas/device.schema'
|
||||
import { Plan } from 'src/billing/schemas/plan.schema'
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
@@ -136,7 +137,7 @@ export class UsersService {
|
||||
user._id.toString(),
|
||||
)
|
||||
|
||||
if (subscription?.plan?.name === 'free') {
|
||||
if ((subscription?.plan as Plan)?.name === 'free') {
|
||||
const devices = await this.deviceModel.find({ user: user._id })
|
||||
|
||||
if (devices.length === 0 || devices.map(device=>device.sentSMSCount + device.receivedSMSCount).reduce((a,b)=>a+b,0) == 0) {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
export enum WebhookEvent {
|
||||
MESSAGE_RECEIVED = 'MESSAGE_RECEIVED',
|
||||
MESSAGE_SENT = 'MESSAGE_SENT',
|
||||
MESSAGE_DELIVERED = 'MESSAGE_DELIVERED',
|
||||
MESSAGE_FAILED = 'MESSAGE_FAILED',
|
||||
UNKNOWN_STATE = 'UNKNOWN_STATE',
|
||||
SMS_STATUS_UPDATED = 'SMS_STATUS_UPDATED',
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Controller,
|
||||
Get,
|
||||
UseGuards,
|
||||
Query,
|
||||
} from '@nestjs/common'
|
||||
import { WebhookService } from './webhook.service'
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'
|
||||
@@ -27,7 +28,30 @@ export class WebhookController {
|
||||
})
|
||||
return { data }
|
||||
}
|
||||
|
||||
@Get('notifications')
|
||||
@UseGuards(AuthGuard)
|
||||
async getWebhookNotifications(
|
||||
@Request() req,
|
||||
@Query('page') page?: number,
|
||||
@Query('limit') limit?: number,
|
||||
@Query('status') status?: string,
|
||||
@Query('eventType') eventType?: string,
|
||||
@Query('deviceId') deviceId?: string,
|
||||
@Query('start') start?: Date,
|
||||
@Query('end') end?: Date,
|
||||
) {
|
||||
const data = await this.webhookService.findWebhookNotificationsForUser({
|
||||
user: req.user,
|
||||
page,
|
||||
limit,
|
||||
eventType,
|
||||
status,
|
||||
start,
|
||||
end,
|
||||
deviceId,
|
||||
})
|
||||
return { data }
|
||||
}
|
||||
@Get(':webhookId')
|
||||
@UseGuards(AuthGuard)
|
||||
async getWebhook(@Request() req, @Param('webhookId') webhookId: string) {
|
||||
|
||||
@@ -15,6 +15,8 @@ import { v4 as uuidv4 } from 'uuid'
|
||||
import { Cron } from '@nestjs/schedule'
|
||||
import { CronExpression } from '@nestjs/schedule'
|
||||
import * as crypto from 'crypto'
|
||||
import mongoose from 'mongoose'
|
||||
import { SMS } from 'src/gateway/schemas/sms.schema'
|
||||
|
||||
@Injectable()
|
||||
export class WebhookService {
|
||||
@@ -40,7 +42,181 @@ export class WebhookService {
|
||||
async findWebhooksForUser({ user }) {
|
||||
return await this.webhookSubscriptionModel.find({ user: user._id })
|
||||
}
|
||||
async findWebhookNotificationsForUser({
|
||||
user,
|
||||
page = 1,
|
||||
limit = 10,
|
||||
eventType,
|
||||
status,
|
||||
start,
|
||||
end,
|
||||
deviceId,
|
||||
}): Promise<{ data: any[]; meta: any }> {
|
||||
const userWebhookSubscription = await this.webhookSubscriptionModel.findOne(
|
||||
{
|
||||
user: user._id,
|
||||
},
|
||||
)
|
||||
|
||||
if (!userWebhookSubscription) {
|
||||
return {
|
||||
data: [],
|
||||
meta: {
|
||||
page: 1,
|
||||
limit,
|
||||
total: 0,
|
||||
totalPages: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const matchStage: any = { webhookSubscription: userWebhookSubscription._id }
|
||||
|
||||
if (eventType) {
|
||||
matchStage.event = eventType
|
||||
}
|
||||
|
||||
if (
|
||||
start &&
|
||||
end &&
|
||||
!Number.isNaN(new Date(start).getTime()) &&
|
||||
!Number.isNaN(new Date(end).getTime())
|
||||
) {
|
||||
matchStage.createdAt = { $gte: new Date(start), $lte: new Date(end) }
|
||||
}
|
||||
|
||||
|
||||
const pageNum = Math.max(1, Number.parseInt(page.toString()) || 1)
|
||||
const limitNum = Math.max(1, Number.parseInt(limit.toString()) || 10)
|
||||
const skip = (pageNum - 1) * limitNum
|
||||
|
||||
const commonPipeline: any[] = [
|
||||
{ $match: matchStage },
|
||||
{
|
||||
$lookup: {
|
||||
from: 'sms',
|
||||
localField: 'sms',
|
||||
foreignField: '_id',
|
||||
as: 'smsData',
|
||||
},
|
||||
},
|
||||
{ $unwind: { path: '$smsData', preserveNullAndEmptyArrays: true } },
|
||||
{
|
||||
$lookup: {
|
||||
from: 'devices',
|
||||
localField: 'smsData.device',
|
||||
foreignField: '_id',
|
||||
as: 'deviceData',
|
||||
},
|
||||
},
|
||||
{ $unwind: { path: '$deviceData', preserveNullAndEmptyArrays: true } },
|
||||
{
|
||||
$lookup: {
|
||||
from: 'webhooksubscriptions',
|
||||
localField: 'webhookSubscription',
|
||||
foreignField: '_id',
|
||||
as: 'webhookSubscriptionData',
|
||||
},
|
||||
},
|
||||
{
|
||||
$unwind: {
|
||||
path: '$webhookSubscriptionData',
|
||||
preserveNullAndEmptyArrays: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
$addFields: {
|
||||
computedStatus: {
|
||||
$cond: {
|
||||
if: { $ne: ['$deliveredAt', null] },
|
||||
then: 'delivered',
|
||||
else: {
|
||||
$cond: {
|
||||
if: {
|
||||
$or: [
|
||||
{ $ne: ['$deliveryAttemptAbortedAt', null] },
|
||||
{ $gte: ['$deliveryAttemptCount', 10] }
|
||||
]
|
||||
},
|
||||
then: 'failed',
|
||||
else: {
|
||||
$cond: {
|
||||
if: {
|
||||
$and: [
|
||||
{ $eq: ['$deliveredAt', null] },
|
||||
{ $eq: ['$deliveryAttemptAbortedAt', null] },
|
||||
{ $gt: ['$deliveryAttemptCount', 0] },
|
||||
{ $lt: ['$deliveryAttemptCount', 10] }
|
||||
]
|
||||
},
|
||||
then: 'retrying',
|
||||
else: 'pending'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
// Apply status filter
|
||||
if (status) {
|
||||
commonPipeline.push({
|
||||
$match: {
|
||||
computedStatus: status
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (deviceId) {
|
||||
commonPipeline.push({
|
||||
$match: {
|
||||
'deviceData._id': new mongoose.Types.ObjectId(deviceId),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const facetPipeline = [
|
||||
...commonPipeline,
|
||||
{
|
||||
$facet: {
|
||||
totalCount: [{ $count: 'count' }],
|
||||
data: [
|
||||
{ $sort: { createdAt: -1 } },
|
||||
{ $skip: skip },
|
||||
{ $limit: limitNum },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
data: 1,
|
||||
totalCount: {
|
||||
$ifNull: [{ $arrayElemAt: ['$totalCount.count', 0] }, 0],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const [result] =
|
||||
await this.webhookNotificationModel.aggregate(facetPipeline)
|
||||
|
||||
const total = result?.totalCount || 0
|
||||
const data = result?.data || []
|
||||
const totalPages = Math.ceil(total / limitNum)
|
||||
|
||||
return {
|
||||
data,
|
||||
meta: {
|
||||
page: pageNum,
|
||||
limit: limitNum,
|
||||
total,
|
||||
totalPages,
|
||||
},
|
||||
}
|
||||
}
|
||||
async create({ user, createWebhookDto }) {
|
||||
const { events, deliveryUrl, signingSecret } = createWebhookDto
|
||||
|
||||
@@ -114,6 +290,17 @@ export class WebhookService {
|
||||
webhookSubscription.signingSecret = updateWebhookDto.signingSecret
|
||||
}
|
||||
|
||||
if (
|
||||
updateWebhookDto.hasOwnProperty('events') &&
|
||||
updateWebhookDto.events.length === 0
|
||||
) {
|
||||
throw new HttpException(
|
||||
'Choose atleast one event to receive',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
)
|
||||
} else if (updateWebhookDto.hasOwnProperty('events')) {
|
||||
webhookSubscription.events = updateWebhookDto.events
|
||||
}
|
||||
await webhookSubscription.save()
|
||||
|
||||
return webhookSubscription
|
||||
@@ -129,17 +316,71 @@ export class WebhookService {
|
||||
if (!webhookSubscription) {
|
||||
return
|
||||
}
|
||||
if (!Object.values(WebhookEvent).includes(event)) {
|
||||
throw new HttpException('Invalid event type', HttpStatus.BAD_REQUEST)
|
||||
}
|
||||
|
||||
|
||||
let payload: Record<string, any>= {
|
||||
smsId: sms._id,
|
||||
message: sms.message,
|
||||
deviceId: sms.device,
|
||||
webhookSubscriptionId: webhookSubscription._id,
|
||||
webhookEvent: event,
|
||||
};
|
||||
|
||||
switch (event) {
|
||||
case WebhookEvent.MESSAGE_RECEIVED:
|
||||
payload = {
|
||||
...payload,
|
||||
sender: sms.sender,
|
||||
receivedAt: sms.receivedAt,
|
||||
};
|
||||
break;
|
||||
|
||||
case WebhookEvent.MESSAGE_DELIVERED:
|
||||
payload = {
|
||||
...payload,
|
||||
smsBatchId: sms.smsBatch,
|
||||
status: sms.status,
|
||||
recipient: sms.recipient,
|
||||
sentAt: sms.sentAt,
|
||||
deliveredAt: sms.deliveredAt,
|
||||
};
|
||||
break;
|
||||
|
||||
case WebhookEvent.MESSAGE_SENT:
|
||||
payload = {
|
||||
...payload,
|
||||
smsBatchId: sms.smsBatch,
|
||||
status: sms.status,
|
||||
recipient: sms.recipient,
|
||||
sentAt: sms.sentAt,
|
||||
};
|
||||
break;
|
||||
|
||||
case WebhookEvent.MESSAGE_FAILED:
|
||||
payload = {
|
||||
...payload,
|
||||
smsBatchId: sms.smsBatch,
|
||||
status: sms.status,
|
||||
recipient: sms.recipient,
|
||||
errorCode: sms.errorCode,
|
||||
errorMessage: sms.errorMessage,
|
||||
failedAt: sms.failedAt,
|
||||
};
|
||||
break;
|
||||
|
||||
case WebhookEvent.UNKNOWN_STATE:
|
||||
payload = {
|
||||
...payload,
|
||||
smsBatchId: sms.smsBatch,
|
||||
status: sms.status,
|
||||
recipient: sms.recipient,
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
if (event === WebhookEvent.MESSAGE_RECEIVED) {
|
||||
const payload = {
|
||||
smsId: sms._id,
|
||||
sender: sms.sender,
|
||||
message: sms.message,
|
||||
receivedAt: sms.receivedAt,
|
||||
deviceId: sms.device,
|
||||
webhookSubscriptionId: webhookSubscription._id,
|
||||
webhookEvent: event,
|
||||
}
|
||||
const webhookNotification = await this.webhookNotificationModel.create({
|
||||
webhookSubscription: webhookSubscription._id,
|
||||
event,
|
||||
@@ -147,10 +388,7 @@ export class WebhookService {
|
||||
sms,
|
||||
})
|
||||
|
||||
await this.attemptWebhookDelivery(webhookNotification)
|
||||
} else {
|
||||
throw new HttpException('Invalid event type', HttpStatus.BAD_REQUEST)
|
||||
}
|
||||
await this.attemptWebhookDelivery(webhookNotification)
|
||||
}
|
||||
|
||||
private async attemptWebhookDelivery(
|
||||
@@ -164,9 +402,7 @@ export class WebhookService {
|
||||
)
|
||||
|
||||
if (!webhookSubscription) {
|
||||
console.log(
|
||||
`Webhook subscription not found for ${webhookSubscriptionId}`,
|
||||
)
|
||||
console.log(`Webhook subscription not found for ${webhookSubscriptionId}`)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -217,7 +453,6 @@ export class WebhookService {
|
||||
|
||||
webhookSubscription.deliveryFailureCount += 1
|
||||
webhookSubscription.lastDeliveryFailureAt = now
|
||||
|
||||
} finally {
|
||||
webhookSubscription.deliveryAttemptCount += 1
|
||||
await webhookSubscription.save()
|
||||
@@ -250,7 +485,7 @@ export class WebhookService {
|
||||
|
||||
// Check for notifications that need to be delivered every 3 minutes
|
||||
@Cron('0 */3 * * * *', {
|
||||
disabled: process.env.NODE_ENV !== 'production'
|
||||
disabled: process.env.NODE_ENV !== 'production',
|
||||
})
|
||||
async checkForNotificationsToDeliver() {
|
||||
const now = new Date()
|
||||
|
||||
+2
-1
@@ -13,4 +13,5 @@ MAIL_USER=
|
||||
MAIL_PASS=
|
||||
MAIL_FROM=
|
||||
|
||||
ADMIN_EMAIL=
|
||||
ADMIN_EMAIL=
|
||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA #test
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { signIn } from 'next-auth/react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useForm } from 'react-hook-form'
|
||||
@@ -17,10 +18,14 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Routes } from '@/config/routes'
|
||||
import { useTurnstile } from '@/lib/turnstile'
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email({ message: 'Invalid email address' }),
|
||||
password: z.string().min(1, { message: 'Password is required' }),
|
||||
turnstileToken: z
|
||||
.string()
|
||||
.min(1, { message: 'Please complete the bot verification' }),
|
||||
})
|
||||
|
||||
type LoginFormValues = z.infer<typeof loginSchema>
|
||||
@@ -33,16 +38,54 @@ export default function LoginForm() {
|
||||
defaultValues: {
|
||||
email: '',
|
||||
password: '',
|
||||
turnstileToken: '',
|
||||
},
|
||||
})
|
||||
|
||||
const {
|
||||
containerRef: turnstileRef,
|
||||
token: turnstileToken,
|
||||
error: turnstileError,
|
||||
} = useTurnstile({
|
||||
siteKey: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY,
|
||||
onToken: (token) =>
|
||||
form.setValue('turnstileToken', token, { shouldValidate: true }),
|
||||
onError: (message) =>
|
||||
form.setError('turnstileToken', { type: 'manual', message }),
|
||||
onExpire: (message) =>
|
||||
form.setError('turnstileToken', { type: 'manual', message }),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (turnstileToken) {
|
||||
form.clearErrors('turnstileToken')
|
||||
}
|
||||
}, [turnstileToken, form])
|
||||
|
||||
useEffect(() => {
|
||||
if (turnstileError) {
|
||||
form.setError('turnstileToken', { type: 'manual', message: turnstileError })
|
||||
}
|
||||
}, [turnstileError, form])
|
||||
|
||||
const onSubmit = async (data: LoginFormValues) => {
|
||||
form.clearErrors()
|
||||
|
||||
if (!data.turnstileToken) {
|
||||
form.setError('turnstileToken', {
|
||||
type: 'manual',
|
||||
message: 'Please complete the bot verification',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await signIn('email-password-login', {
|
||||
redirect: true,
|
||||
callbackUrl: Routes.dashboard,
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
turnstileToken: data.turnstileToken,
|
||||
})
|
||||
|
||||
if (result?.error) {
|
||||
@@ -97,6 +140,21 @@ export default function LoginForm() {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='turnstileToken'
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div
|
||||
ref={turnstileRef}
|
||||
className='min-h-[65px] w-full flex justify-center'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{form.formState.errors.root && (
|
||||
<p className='text-sm font-medium text-red-500'>
|
||||
{form.formState.errors.root.message}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
import { signIn } from 'next-auth/react'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Routes } from '@/config/routes'
|
||||
import { useTurnstile } from '@/lib/turnstile'
|
||||
|
||||
const registerSchema = z.object({
|
||||
name: z
|
||||
@@ -29,6 +31,9 @@ const registerSchema = z.object({
|
||||
.min(8, { message: 'Password must be at least 8 characters long' }),
|
||||
phone: z.string().optional(),
|
||||
marketingOptIn: z.boolean().optional().default(true),
|
||||
turnstileToken: z
|
||||
.string()
|
||||
.min(1, { message: 'Please complete the bot verification' }),
|
||||
})
|
||||
|
||||
type RegisterFormValues = z.infer<typeof registerSchema>
|
||||
@@ -44,12 +49,47 @@ export default function RegisterForm() {
|
||||
password: '',
|
||||
phone: '',
|
||||
marketingOptIn: true,
|
||||
turnstileToken: '',
|
||||
},
|
||||
})
|
||||
|
||||
const {
|
||||
containerRef: turnstileRef,
|
||||
token: turnstileToken,
|
||||
error: turnstileError,
|
||||
} = useTurnstile({
|
||||
siteKey: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY,
|
||||
onToken: (token) =>
|
||||
form.setValue('turnstileToken', token, { shouldValidate: true }),
|
||||
onError: (message) =>
|
||||
form.setError('turnstileToken', { type: 'manual', message }),
|
||||
onExpire: (message) =>
|
||||
form.setError('turnstileToken', { type: 'manual', message }),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (turnstileToken) {
|
||||
form.clearErrors('turnstileToken')
|
||||
}
|
||||
}, [turnstileToken, form])
|
||||
|
||||
useEffect(() => {
|
||||
if (turnstileError) {
|
||||
form.setError('turnstileToken', { type: 'manual', message: turnstileError })
|
||||
}
|
||||
}, [turnstileError, form])
|
||||
|
||||
const onSubmit = async (data: RegisterFormValues) => {
|
||||
form.clearErrors()
|
||||
|
||||
if (!data.turnstileToken) {
|
||||
form.setError('turnstileToken', {
|
||||
type: 'manual',
|
||||
message: 'Please complete the bot verification',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await signIn('email-password-register', {
|
||||
redirect: false,
|
||||
@@ -58,6 +98,7 @@ export default function RegisterForm() {
|
||||
name: data.name,
|
||||
phone: data.phone,
|
||||
marketingOptIn: data.marketingOptIn,
|
||||
turnstileToken: data.turnstileToken,
|
||||
})
|
||||
|
||||
if (result?.error) {
|
||||
@@ -133,6 +174,21 @@ export default function RegisterForm() {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='turnstileToken'
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div
|
||||
ref={turnstileRef}
|
||||
className='min-h-[65px] w-full flex justify-center'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{form.formState.errors.root && (
|
||||
<p className='text-sm font-medium text-red-500'>
|
||||
{form.formState.errors.root.message}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useEffect } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import * as z from 'zod'
|
||||
@@ -28,9 +29,13 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import httpBrowserClient from '@/lib/httpBrowserClient'
|
||||
import { ApiEndpoints } from '@/config/api'
|
||||
import { Routes } from '@/config/routes'
|
||||
import { useTurnstile } from '@/lib/turnstile'
|
||||
|
||||
const requestPasswordResetSchema = z.object({
|
||||
email: z.string().email({ message: 'Invalid email address' }),
|
||||
turnstileToken: z
|
||||
.string()
|
||||
.min(1, { message: 'Please complete the bot verification' }),
|
||||
})
|
||||
|
||||
type RequestPasswordResetFormValues = z.infer<typeof requestPasswordResetSchema>
|
||||
@@ -40,13 +45,49 @@ export default function RequestPasswordResetForm() {
|
||||
resolver: zodResolver(requestPasswordResetSchema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
turnstileToken: '',
|
||||
},
|
||||
})
|
||||
|
||||
const {
|
||||
containerRef: turnstileRef,
|
||||
token: turnstileToken,
|
||||
error: turnstileError,
|
||||
} = useTurnstile({
|
||||
siteKey: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY,
|
||||
onToken: (token) =>
|
||||
form.setValue('turnstileToken', token, { shouldValidate: true }),
|
||||
onError: (message) =>
|
||||
form.setError('turnstileToken', { type: 'manual', message }),
|
||||
onExpire: (message) =>
|
||||
form.setError('turnstileToken', { type: 'manual', message }),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (turnstileToken) {
|
||||
form.clearErrors('turnstileToken')
|
||||
}
|
||||
}, [turnstileToken, form])
|
||||
|
||||
useEffect(() => {
|
||||
if (turnstileError) {
|
||||
form.setError('turnstileToken', { type: 'manual', message: turnstileError })
|
||||
}
|
||||
}, [turnstileError, form])
|
||||
|
||||
const onRequestPasswordReset = async (
|
||||
data: RequestPasswordResetFormValues
|
||||
) => {
|
||||
form.clearErrors()
|
||||
|
||||
if (!data.turnstileToken) {
|
||||
form.setError('turnstileToken', {
|
||||
type: 'manual',
|
||||
message: 'Please complete the bot verification',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await httpBrowserClient.post(
|
||||
ApiEndpoints.auth.requestPasswordReset(),
|
||||
@@ -89,6 +130,21 @@ export default function RequestPasswordResetForm() {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='turnstileToken'
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div
|
||||
ref={turnstileRef}
|
||||
className='min-h-[65px] w-full flex justify-center'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
className='w-full'
|
||||
type='submit'
|
||||
|
||||
@@ -29,8 +29,8 @@ export default function CheckoutPage({ params }) {
|
||||
if (retries > 0) {
|
||||
initiateCheckout(retries - 1)
|
||||
} else {
|
||||
setError('Failed to create checkout session. Please try again.')
|
||||
console.error(error)
|
||||
setError(error.response?.data?.message || 'Failed to create checkout session. Please try again or contact billing@textbee.dev.')
|
||||
console.error(error.response?.data?.message)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
Wallet,
|
||||
Shield,
|
||||
Coins,
|
||||
Twitter,
|
||||
Linkedin,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { ExternalLinks } from '@/config/external-links'
|
||||
@@ -54,7 +56,7 @@ export default function ContributePage() {
|
||||
</div>
|
||||
|
||||
<div className='space-y-6 max-w-5xl mx-auto'>
|
||||
<Card className='overflow-hidden'>
|
||||
<Card className='overflow-hidden hidden'>
|
||||
<CardHeader>
|
||||
<CardTitle className='flex items-center gap-2'>
|
||||
<CircleDollarSign className='h-5 w-5' />
|
||||
@@ -244,16 +246,31 @@ export default function ContributePage() {
|
||||
Join the Community
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Connect with other contributors and users
|
||||
Follow our socials and connect with other contributors to get
|
||||
early access to new features and updates.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button className='w-full md:w-auto' variant='outline' asChild>
|
||||
<Link href={ExternalLinks.discord} target='_blank'>
|
||||
<MessageSquare className='mr-2 h-4 w-4' />
|
||||
Join Discord
|
||||
</Link>
|
||||
</Button>
|
||||
<div className='grid grid-cols-1 md:grid-cols-3 gap-3'>
|
||||
<Button variant='outline' asChild>
|
||||
<Link href={ExternalLinks.discord} target='_blank'>
|
||||
<MessageSquare className='mr-2 h-4 w-4' />
|
||||
Join Discord
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant='outline' asChild>
|
||||
<Link href={ExternalLinks.twitter} target='_blank'>
|
||||
<Twitter className='mr-2 h-4 w-4' />
|
||||
Follow us on X (Twitter)
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant='outline' asChild>
|
||||
<Link href={ExternalLinks.linkedin} target='_blank'>
|
||||
<Linkedin className='mr-2 h-4 w-4' />
|
||||
Connect on LinkedIn
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -169,7 +169,7 @@ export default function ApiKeys() {
|
||||
)}
|
||||
|
||||
{apiKeys?.data?.map((apiKey) => (
|
||||
<Card key={apiKey.id} className='border-0 shadow-none'>
|
||||
<Card key={apiKey._id} className='border-0 shadow-none'>
|
||||
<CardContent className='flex items-center p-3'>
|
||||
<Key className='h-6 w-6 mr-3' />
|
||||
<div className='flex-1'>
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import httpBrowserClient from '@/lib/httpBrowserClient'
|
||||
import { ApiEndpoints } from '@/config/api'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Copy, Check } from 'lucide-react'
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
DISMISSED: 'black_friday_modal_dismissed',
|
||||
LAST_SHOWN: 'black_friday_modal_last_shown',
|
||||
}
|
||||
|
||||
const SHOW_INTERVAL = 6 * 60 * 60 * 1000 // 6 hours
|
||||
|
||||
export default function BlackFridayModal() {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [copiedCode, setCopiedCode] = useState<string | null>(null)
|
||||
|
||||
const { data: currentSubscription, isLoading } = useQuery({
|
||||
queryKey: ['currentSubscription'],
|
||||
queryFn: () =>
|
||||
httpBrowserClient
|
||||
.get(ApiEndpoints.billing.currentSubscription())
|
||||
.then((res) => res.data),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || !currentSubscription) return
|
||||
|
||||
// Only show for free plan
|
||||
if (currentSubscription?.plan?.name !== 'free') return
|
||||
|
||||
// Check if permanently dismissed
|
||||
if (localStorage.getItem(STORAGE_KEYS.DISMISSED) === 'true') return
|
||||
|
||||
// Check last shown time
|
||||
const lastShown = localStorage.getItem(STORAGE_KEYS.LAST_SHOWN)
|
||||
const now = Date.now()
|
||||
if (lastShown && now - parseInt(lastShown) < SHOW_INTERVAL) return
|
||||
|
||||
// Show modal
|
||||
setIsOpen(true)
|
||||
}, [currentSubscription, isLoading])
|
||||
|
||||
const handleDismiss = () => {
|
||||
localStorage.setItem(STORAGE_KEYS.DISMISSED, 'true')
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const handleRemindLater = () => {
|
||||
localStorage.setItem(STORAGE_KEYS.LAST_SHOWN, Date.now().toString())
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const copyCode = (code: string) => {
|
||||
navigator.clipboard.writeText(code)
|
||||
setCopiedCode(code)
|
||||
setTimeout(() => setCopiedCode(null), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && handleRemindLater()}>
|
||||
<DialogContent className="sm:max-w-xl border-2 border-purple-500/20 shadow-2xl shadow-purple-500/10">
|
||||
<DialogHeader>
|
||||
<div className="mx-auto bg-gradient-to-r from-purple-600 to-pink-600 text-white text-xs font-bold px-3 py-1 rounded-full mb-2 animate-pulse">
|
||||
LIMITED TIME OFFER
|
||||
</div>
|
||||
<DialogTitle className="text-2xl font-bold text-center flex items-center justify-center gap-2">
|
||||
🔥 Black Friday Sale
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-center text-base">
|
||||
Upgrade to Pro and unlock the full potential of textbee.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{/* Benefits List */}
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
{[
|
||||
"Increased SMS limits",
|
||||
"No daily limits",
|
||||
"No bulk send limits",
|
||||
"Priority support",
|
||||
].map((benefit, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<div className="h-5 w-5 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center flex-shrink-0">
|
||||
<Check className="h-3 w-3 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<span>{benefit}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
<div className="bg-muted p-4 rounded-lg space-y-3 flex flex-col justify-between">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">Monthly Plan</span>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground line-through">$9.99</span>
|
||||
<span className="font-bold text-green-600">$5.99</span>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="secondary" className="bg-black text-white hover:bg-black/90">40% OFF</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 bg-background p-2 rounded border border-dashed border-primary/50">
|
||||
<code className="flex-1 font-mono font-bold text-center text-primary text-sm">BLACKFRIDAY40</code>
|
||||
<Button size="icon" variant="ghost" className="h-8 w-8" onClick={() => copyCode('BLACKFRIDAY40')}>
|
||||
{copiedCode === 'BLACKFRIDAY40' ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative bg-gradient-to-br from-purple-50 to-pink-50 dark:from-purple-950/20 dark:to-pink-950/20 p-4 rounded-lg space-y-3 border border-purple-200 dark:border-purple-800 flex flex-col justify-between">
|
||||
<div className="absolute -top-3 right-4 bg-gradient-to-r from-amber-500 to-orange-500 text-white text-[10px] font-bold px-2 py-0.5 rounded-full shadow-sm">
|
||||
BEST VALUE
|
||||
</div>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">Yearly Plan</span>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground line-through">$99.90</span>
|
||||
<span className="font-bold text-green-600">$49.90</span>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="secondary" className="bg-black text-white hover:bg-black/90">50% OFF</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 bg-background p-2 rounded border border-dashed border-primary/50">
|
||||
<code className="flex-1 font-mono font-bold text-center text-primary text-sm">BLACKFRIDAY50</code>
|
||||
<Button size="icon" variant="ghost" className="h-8 w-8" onClick={() => copyCode('BLACKFRIDAY50')}>
|
||||
{copiedCode === 'BLACKFRIDAY50' ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-center text-muted-foreground">
|
||||
Apply code at checkout to redeem.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-col sm:flex-col gap-2">
|
||||
<Button className="w-full bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white font-bold h-11 shadow-lg shadow-purple-500/20" asChild>
|
||||
<Link href="/checkout/pro">
|
||||
Claim Offer Now
|
||||
</Link>
|
||||
</Button>
|
||||
<p className="text-[10px] text-center text-muted-foreground">
|
||||
Cancel anytime. Secure payment.
|
||||
</p>
|
||||
<div className="flex items-center justify-between w-full pt-1">
|
||||
<Button variant="ghost" size="sm" onClick={handleRemindLater} className="text-muted-foreground h-auto py-1">
|
||||
Remind me later
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={handleDismiss} className="text-muted-foreground hover:text-destructive h-auto py-1">
|
||||
Don't show again
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -27,6 +27,9 @@ import { ApiEndpoints } from '@/config/api'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import httpBrowserClient from '@/lib/httpBrowserClient'
|
||||
import { formatError } from '@/lib/utils/errorHandler'
|
||||
import { RateLimitError } from '@/components/shared/rate-limit-error'
|
||||
import { formatDeviceName } from '@/lib/utils'
|
||||
|
||||
const DEFAULT_MAX_FILE_SIZE = 1024 * 1024 // 1 MB
|
||||
const DEFAULT_MAX_ROWS = 50
|
||||
@@ -108,6 +111,9 @@ export default function BulkSMSSend() {
|
||||
return row[key.trim()] || ''
|
||||
}),
|
||||
recipients: [row[selectedColumn]],
|
||||
...(selectedSimSubscriptionId !== undefined && {
|
||||
simSubscriptionId: selectedSimSubscriptionId,
|
||||
}),
|
||||
}))
|
||||
const payload = {
|
||||
messageTemplate,
|
||||
@@ -120,6 +126,9 @@ export default function BulkSMSSend() {
|
||||
}
|
||||
|
||||
const [selectedDeviceId, setSelectedDeviceId] = useState<string | null>(null)
|
||||
const [selectedSimSubscriptionId, setSelectedSimSubscriptionId] = useState<
|
||||
number | undefined
|
||||
>(undefined)
|
||||
|
||||
const { data: devices } = useQuery({
|
||||
queryKey: ['devices'],
|
||||
@@ -139,6 +148,17 @@ export default function BulkSMSSend() {
|
||||
mutationFn: handleSendBulkSMS,
|
||||
})
|
||||
|
||||
const selectedDevice = devices?.data?.find(
|
||||
(device) => device._id === selectedDeviceId
|
||||
)
|
||||
|
||||
const availableSims =
|
||||
selectedDevice?.simInfo?.sims &&
|
||||
Array.isArray(selectedDevice.simInfo.sims) &&
|
||||
selectedDevice.simInfo.sims.length > 0
|
||||
? selectedDevice.simInfo.sims
|
||||
: []
|
||||
|
||||
const isStep2Disabled = csvData.length === 0
|
||||
const isStep3Disabled = isStep2Disabled || !selectedColumn || !messageTemplate
|
||||
|
||||
@@ -203,7 +223,10 @@ export default function BulkSMSSend() {
|
||||
<div>
|
||||
<Label htmlFor='device-select'>Select Device</Label>
|
||||
<Select
|
||||
onValueChange={setSelectedDeviceId}
|
||||
onValueChange={(value) => {
|
||||
setSelectedDeviceId(value)
|
||||
setSelectedSimSubscriptionId(undefined)
|
||||
}}
|
||||
value={selectedDeviceId}
|
||||
>
|
||||
<SelectTrigger id='device-select'>
|
||||
@@ -216,7 +239,7 @@ export default function BulkSMSSend() {
|
||||
value={device._id}
|
||||
disabled={!device.enabled}
|
||||
>
|
||||
{device.brand} - {device.model}{' '}
|
||||
{formatDeviceName(device)}{' '}
|
||||
{device.enabled ? '' : ' (disabled)'}
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -224,6 +247,34 @@ export default function BulkSMSSend() {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{availableSims.length > 1 && (
|
||||
<div>
|
||||
<Label htmlFor='sim-select'>Select SIM (optional)</Label>
|
||||
<Select
|
||||
onValueChange={(value) =>
|
||||
setSelectedSimSubscriptionId(
|
||||
value ? Number(value) : undefined
|
||||
)
|
||||
}
|
||||
value={selectedSimSubscriptionId?.toString()}
|
||||
>
|
||||
<SelectTrigger id='sim-select'>
|
||||
<SelectValue placeholder='Select SIM (optional)' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableSims.map((sim) => (
|
||||
<SelectItem
|
||||
key={sim.subscriptionId}
|
||||
value={sim.subscriptionId.toString()}
|
||||
>
|
||||
{sim.displayName || 'SIM'} ({sim.subscriptionId})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='space-y-4'>
|
||||
<div>
|
||||
<Label htmlFor='recipient-column'>
|
||||
@@ -321,15 +372,27 @@ export default function BulkSMSSend() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{sendingBulkSMSError && (
|
||||
<Alert variant='destructive' className='mt-4'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>
|
||||
{sendingBulkSMSError?.message}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{sendingBulkSMSError && (() => {
|
||||
const formattedError = formatError(sendingBulkSMSError)
|
||||
if (formattedError.isRateLimit) {
|
||||
return (
|
||||
<RateLimitError
|
||||
errorData={formattedError.rateLimitData}
|
||||
variant="alert"
|
||||
className="mt-4"
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Alert variant='destructive' className='mt-4'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>
|
||||
{formattedError.message}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
})()}
|
||||
|
||||
{isSendingBulkSMSuccess && (
|
||||
<Alert variant='default' className='mt-4'>
|
||||
|
||||
@@ -1,13 +1,84 @@
|
||||
'use client'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Github, Heart, MessageSquare } from 'lucide-react'
|
||||
import {
|
||||
Github,
|
||||
Heart,
|
||||
MessageSquare,
|
||||
Linkedin,
|
||||
Twitter,
|
||||
Share2,
|
||||
LinkIcon,
|
||||
} from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { ExternalLinks } from '@/config/external-links'
|
||||
import { useState } from 'react'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
} from '@/components/ui/dialog'
|
||||
|
||||
export default function CommunityLinks() {
|
||||
const [socialOpen, setSocialOpen] = useState(false)
|
||||
const [copiedUrl, setCopiedUrl] = useState('')
|
||||
const socials = [
|
||||
{
|
||||
icon: '/images/facebook.svg',
|
||||
name: 'Facebook',
|
||||
url: 'https://www.facebook.com/sharer/sharer.php?u=https://textbee.dev',
|
||||
},
|
||||
{
|
||||
icon: '/images/x.svg',
|
||||
name: 'Twitter',
|
||||
url: 'https://twitter.com/intent/tweet?url=https://textbee.dev&text=🚀+Just+discovered+@textbeedotdev+-+an+amazing+SMS+gateway+platform!+Perfect+for+those+who+need+reliable+sms+integration.',
|
||||
},
|
||||
{
|
||||
icon: '/images/linkedin2.svg',
|
||||
name: 'LinkedIn',
|
||||
url: 'https://www.linkedin.com/sharing/share-offsite/?url=https://textbee.dev',
|
||||
},
|
||||
{
|
||||
icon: '/images/reddit.svg',
|
||||
name: 'Reddit',
|
||||
url: 'https://www.reddit.com/submit?url=https://textbee.dev&title=textbee.dev+-+Developer-friendly+SMS+Gateway+Platform',
|
||||
},
|
||||
{
|
||||
icon: '/images/whatsapp.svg',
|
||||
name: 'WhatsApp',
|
||||
url: "https://api.whatsapp.com/send?text=Hey!+Check+out+textbee.dev+-+it's+a+fantastic+SMS+gateway+platform+perfect+for+those+who+need+reliable+sms+integration+🚀+https://textbee.dev",
|
||||
},
|
||||
{
|
||||
icon: '/images/telegram.svg',
|
||||
name: 'Telegram',
|
||||
url: 'https://t.me/share/url?url=https://textbee.dev&text=🔥+Found+an+awesome+SMS+gateway+platform+-+textbee.dev!+Great+for+those+who+need+reliable+sms+integration.',
|
||||
},
|
||||
{
|
||||
icon: '/images/gmail.svg',
|
||||
name: 'Email',
|
||||
url: "mailto:?subject=Check+out+textbee.dev+-+SMS+Gateway+Platform&body=Hi!%0A%0AI+wanted+to+share+textbee.dev+with+you+-+it's+an+excellent+SMS+gateway+platform+that's+perfect+for+those+who+need+reliable+sms+integration.%0A%0ACheck+it+out:+https://textbee.dev%0A%0ABest+regards!",
|
||||
},
|
||||
]
|
||||
const [currentUrl, setCurrentUrl] = useState(socials[0].url)
|
||||
|
||||
const copyToClipboard = (content: string) => {
|
||||
navigator.clipboard.writeText(content)
|
||||
setCopiedUrl(content)
|
||||
|
||||
toast({
|
||||
title: 'Link copied!',
|
||||
description: 'The Link has been copied to your clipboard.',
|
||||
})
|
||||
setTimeout(() => setCopiedUrl(''), 3000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
{/* <Card>
|
||||
<>
|
||||
<div className='grid gap-4 md:grid-cols-2 lg:grid-cols-2'>
|
||||
{/* <Card>
|
||||
<CardHeader>
|
||||
<CardTitle>One-time Donation</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -24,7 +95,7 @@ export default function CommunityLinks() {
|
||||
</CardContent>
|
||||
</Card> */}
|
||||
|
||||
{/* <Card>
|
||||
{/* <Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Support on Patreon</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -41,39 +112,164 @@ export default function CommunityLinks() {
|
||||
</CardContent>
|
||||
</Card> */}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>GitHub</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className='text-sm text-muted-foreground mb-4'>
|
||||
Check out our source code and contribute to the project.
|
||||
</p>
|
||||
<Link href={ExternalLinks.github} prefetch={false} target='_blank'>
|
||||
<Button className='w-full'>
|
||||
<Github className='mr-2 h-4 w-4' />
|
||||
View Source
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>GitHub</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className='text-sm text-muted-foreground mb-4'>
|
||||
Check out our source code and contribute to the project.
|
||||
</p>
|
||||
<Link href={ExternalLinks.github} prefetch={false} target='_blank'>
|
||||
<Button className='w-full'>
|
||||
<Github className='mr-2 h-4 w-4' />
|
||||
View Source
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Discord</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className='text-sm text-muted-foreground mb-4'>
|
||||
Join our community for support and updates.
|
||||
</p>
|
||||
<Link href={ExternalLinks.discord} prefetch={false} target='_blank'>
|
||||
<Button className='w-full' variant='outline'>
|
||||
<MessageSquare className='mr-2 h-4 w-4' />
|
||||
Join Discord
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Discord</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className='text-sm text-muted-foreground mb-4'>
|
||||
Join our community for support and updates.
|
||||
</p>
|
||||
<Link href={ExternalLinks.discord} prefetch={false} target='_blank'>
|
||||
<Button className='w-full' variant='outline'>
|
||||
<MessageSquare className='mr-2 h-4 w-4' />
|
||||
Join Discord
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>X (Twitter)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className='text-sm text-muted-foreground mb-4'>
|
||||
Follow us on X for the latest updates and announcements.
|
||||
</p>
|
||||
<Link href={ExternalLinks.twitter} prefetch={false} target='_blank'>
|
||||
<Button className='w-full' variant='outline'>
|
||||
<Twitter className='mr-2 h-4 w-4' />
|
||||
Follow on X
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>LinkedIn</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className='text-sm text-muted-foreground mb-4'>
|
||||
Connect with us on LinkedIn for updates and news.
|
||||
</p>
|
||||
<Link
|
||||
href={ExternalLinks.linkedin}
|
||||
prefetch={false}
|
||||
target='_blank'
|
||||
>
|
||||
<Button className='w-full' variant='outline'>
|
||||
<Linkedin className='mr-2 h-4 w-4' />
|
||||
Connect on LinkedIn
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Spread the Word</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className='text-sm text-muted-foreground mb-4'>
|
||||
Help others discover textbee.dev by sharing it with your network.
|
||||
</p>
|
||||
|
||||
<Button
|
||||
variant='outline'
|
||||
className='w-full'
|
||||
onClick={() => setSocialOpen(true)}
|
||||
>
|
||||
<Share2 className='mr-2 h-4 w-4' /> Share textbee.dev
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<Dialog open={socialOpen} onOpenChange={setSocialOpen}>
|
||||
<DialogContent className='sm:max-w-[600px] min-w-[500px] text-base'>
|
||||
<DialogHeader>
|
||||
<DialogTitle className='text-primary mb-2 text-2xl font-bold'>
|
||||
Share textbee.dev with Others
|
||||
</DialogTitle>
|
||||
<p className='text-muted-foreground'>
|
||||
Help us grow by sharing textbee.dev with your friends and
|
||||
colleagues!
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='flex flex-col gap-6 mt-4'>
|
||||
<div className='space-y-3'>
|
||||
<h3 className='text-lg font-semibold'>Choose your platform</h3>
|
||||
<div className='grid grid-cols-4 gap-3 p-4 bg-muted/30 rounded-lg'>
|
||||
{socials.map(({ icon, name, url }) => (
|
||||
<button
|
||||
key={name}
|
||||
type='button'
|
||||
title={name}
|
||||
onClick={() => setCurrentUrl(url)}
|
||||
className={`${
|
||||
currentUrl === url
|
||||
? 'ring-2 ring-primary bg-primary/10 shadow-lg'
|
||||
: 'hover:bg-card hover:shadow-md'
|
||||
} p-3 rounded-xl border bg-card/80 backdrop-blur-sm transition-all duration-200 hover:scale-105 group`}
|
||||
>
|
||||
<div className='w-10 h-10 mx-auto bg-white dark:bg-white rounded-lg p-1 shadow-sm group-hover:shadow-md transition-shadow'>
|
||||
<Image src={icon} alt={name} width={20} height={20} className='w-full h-full object-contain' />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-3'>
|
||||
<h3 className='text-lg font-semibold'>Share link</h3>
|
||||
<div className='flex items-center gap-3 p-3 bg-muted/50 rounded-lg border'>
|
||||
<LinkIcon className='w-5 h-5 flex-shrink-0 text-primary' />
|
||||
<Link
|
||||
href={currentUrl}
|
||||
className='text-sm text-muted-foreground hover:text-foreground break-all flex-1 transition-colors'
|
||||
target='_blank'
|
||||
>
|
||||
{currentUrl}
|
||||
</Link>
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
onClick={() => copyToClipboard(currentUrl)}
|
||||
className='flex-1'
|
||||
variant={copiedUrl === currentUrl ? 'secondary' : 'default'}
|
||||
>
|
||||
{copiedUrl === currentUrl ? 'Copied!' : 'Copy Link'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => window.open(currentUrl, '_blank')}
|
||||
variant='outline'
|
||||
className='flex-1'
|
||||
>
|
||||
Open & Share
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
|
||||
import { Heart, UserCircle } from 'lucide-react'
|
||||
import CommunityAlert from './community-alert'
|
||||
import MainDashboard from './main-dashboard'
|
||||
import CommunityLinks from './community-links'
|
||||
import AccountSettings from './account-settings'
|
||||
import GenerateApiKey from './generate-api-key'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { JoinCommunityModal } from '@/components/shared/join-community-modal'
|
||||
import { ContributeModal } from '@/components/shared/contribute-modal'
|
||||
import UpgradeToProAlert from './upgrade-to-pro-alert'
|
||||
import VerifyEmailAlert from './verify-email-alert'
|
||||
import AccountDeletionAlert from './account-deletion-alert'
|
||||
|
||||
export default function Dashboard({
|
||||
children,
|
||||
}: {
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
const [currentTab, setCurrentTab] = useState('dashboard')
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
setCurrentTab(value)
|
||||
}
|
||||
|
||||
const { data: session } = useSession()
|
||||
|
||||
const welcomeMessage =
|
||||
new Date().getHours() < 12
|
||||
? 'Good morning'
|
||||
: new Date().getHours() < 18
|
||||
? 'Good afternoon'
|
||||
: 'Good evening'
|
||||
|
||||
return (
|
||||
<div className='flex-1 space-y-4 p-4 pt-6 md:p-8'>
|
||||
<div className='flex items-center justify-between space-y-2 flex-col md:flex-row'>
|
||||
<h2 className='text-2xl font-bold tracking-tight'>
|
||||
{welcomeMessage}, {session?.user?.name}
|
||||
</h2>
|
||||
<div className='hidden md:flex items-center space-x-2 py-4'>
|
||||
<GenerateApiKey />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
value={currentTab}
|
||||
onValueChange={handleTabChange}
|
||||
className='space-y-4'
|
||||
>
|
||||
<TabsList className='flex'>
|
||||
<TabsTrigger value='dashboard' className='flex-1'>
|
||||
Dashboard
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value='community' className='flex-1'>
|
||||
<Heart className='mr-2 h-4 w-4' />
|
||||
Community
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value='account' className='flex-1'>
|
||||
<UserCircle className='mr-2 h-4 w-4' />
|
||||
Account
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='dashboard' className='space-y-4'>
|
||||
<AccountDeletionAlert />
|
||||
<CommunityAlert />
|
||||
<VerifyEmailAlert />
|
||||
<UpgradeToProAlert />
|
||||
<MainDashboard />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='community' className='space-y-4'>
|
||||
<AccountDeletionAlert />
|
||||
<VerifyEmailAlert />
|
||||
<UpgradeToProAlert />
|
||||
<CommunityLinks />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='account' className='space-y-4'>
|
||||
<AccountDeletionAlert />
|
||||
<CommunityAlert />
|
||||
<VerifyEmailAlert />
|
||||
<UpgradeToProAlert />
|
||||
<AccountSettings />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<JoinCommunityModal />
|
||||
<ContributeModal />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user