Compare commits

...
38 Commits
Author SHA1 Message Date
isra el d613217efb Merge branch 'main' of github.com:vernu/textbee 2026-03-11 11:47:35 +03:00
isra el 598d697306 chore(android): bump version to 2.7.1(17) 2026-03-11 11:47:21 +03:00
Israel Abebe 5ae010f1c3 Merge pull request #199 from vernu/dev
better handling of failed sms error messaging
2026-03-11 11:45:17 +03:00
isra el 94a0cd8c0a chore(android): graceful exit when sticky notifcation cant be started 2026-03-11 11:44:46 +03:00
isra el 2ec9207779 fix(android): fix sim card info retrieval exception on older android versions 2026-03-11 11:26:32 +03:00
isra el 51cb1ca198 chore(api): track sms sending failure due to FCM issue 2026-03-11 11:09:30 +03:00
isra el e49f3954c3 chore(android): better handling of failed sms error messaging 2026-03-11 11:07:57 +03:00
Israel Abebe 17351e2148 Merge pull request #198 from vernu/dev
add sms sending delay and retry config
2026-03-11 11:07:48 +03:00
isra el 8eec17ba1b chore(android): set default sms delay to 5 sec to avoid carrier/device throttling 2026-03-11 11:06:01 +03:00
isra el 25c70eb09d feat(api): save default sms sending delay config 2026-03-11 11:04:12 +03:00
isra el acde97f080 feat(android): add app sms sending delay and retry config 2026-03-11 10:20:55 +03:00
Israel Abebe e14b71a790 Merge pull request #196 from vernu/dev
update email template
2026-03-06 14:03:52 +03:00
isra el 5023f80f0f chore(api): update email template 2026-03-06 14:03:15 +03:00
Israel Abebe 2bdcdeb9a9 Merge pull request #195 from vernu/dev
skip automatic disabling of webhook subscriptions if theres a recent successful delivery
2026-03-06 13:59:07 +03:00
isra el b42a681816 chore(api): skip automatic disabling of webhook subscriptions if theres a recent successful delivery 2026-03-06 13:42:15 +03:00
Israel Abebe 14fd2f9b03 Merge pull request #194 from vernu/dev
mark undelivered webhook notifications older than one month as aborted to prevent further retries
2026-03-05 19:53:03 +03:00
isra el 540bb9068b feat(api): mark notifications older than one month as aborted to prevent retries 2026-03-05 19:50:48 +03:00
Israel Abebe 3061bd8d66 Merge pull request #193 from vernu/dev
fix webhooksubscription save issue
2026-03-05 11:55:13 +03:00
isra el 338fb665bc fix(api): fix webhooksubscription save issue 2026-03-05 11:54:20 +03:00
Israel Abebe 71475f0db8 Merge pull request #192 from vernu/dev
loosen webhook subscription auto-disable logic
2026-03-05 11:44:07 +03:00
isra el 40b0df297c chore(api): loosen webhook subscription auto-disable logic 2026-03-05 11:43:20 +03:00
Israel Abebe 863aac04e8 Merge pull request #191 from vernu/webhooks-improvements
auto-disable webhook subscriptions with high failure rate and increase delivery request timeout
2026-03-05 11:18:35 +03:00
isra el 6e7ed42fe8 fix(api): fix failing test 2026-03-05 11:15:29 +03:00
isra el 8fd06f06d1 feat(api): auto-disable webhook ubscriptions with high failure rate 2026-03-05 11:01:51 +03:00
isra el 0144cff16e chore(api): update webhook notifications delivery check cron time and query 2026-03-05 10:33:59 +03:00
isra el 7ff6ea23fc chore(api): update webhook notification delivery timeout to 30ms 2026-03-05 10:33:20 +03:00
Israel Abebe fd4969c92c Merge pull request #190 from vernu/webhooks-improvement
improve webhook notifications delivery reliability
2026-03-04 13:32:00 +03:00
isra el 4d1b53e247 chore(api): improve webhook notifications delivery reliability 2026-03-04 13:11:24 +03:00
Israel Abebe c668530188 Merge pull request #189 from vernu/support-hq-widget
add supporthq-customer support widget
2026-03-04 13:02:31 +03:00
isra el 3cd1d94b20 feat(web): add supporthq-customer support widget 2026-03-04 13:01:24 +03:00
Israel Abebe 30591c4847 Merge pull request #186 from vernu/update-email-template
update email template
2026-02-09 10:22:34 +03:00
isra el f5bef39cec chore(api): update email template 2026-02-09 10:21:48 +03:00
Israel Abebe 482049a764 Merge pull request #185 from vernu/pro-cta
improve upgrade to pro cta
2026-02-09 06:55:19 +03:00
isra el ac65907453 improve upgrade to pro cta 2026-02-09 06:54:35 +03:00
Israel Abebe 584e2c5bec Merge pull request #184 from vernu/email-templates
update email templates
2026-02-09 06:48:09 +03:00
Israel Abebe 9685ecb7b3 Merge pull request #183 from vernu/update-discounts-logic
handle missing discountId
2026-02-09 06:47:42 +03:00
isra el d8460bcb8b chore(api): update email templates 2026-02-09 06:39:21 +03:00
isra el eb94085f6e chore(api): handle missing discountId 2026-02-09 06:32:58 +03:00
34 changed files with 1179 additions and 308 deletions
+2 -2
View File
@@ -9,8 +9,8 @@ android {
defaultConfig {
minSdk 24
targetSdk 32
versionCode 16
versionName "2.7.0"
versionCode 17
versionName "2.7.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -24,4 +24,7 @@ public class AppConstants {
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";
public static final String SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY = "SMS_SEND_DELAY_SECONDS";
/** Default delay between SMS sends (seconds). 5s helps avoid carrier/device throttling. */
public static final int DEFAULT_SMS_SEND_DELAY_SECONDS = 5;
}
@@ -199,9 +199,17 @@ public class TextBeeUtils {
Log.d(TAG, "Could not get SIM slot index for subscription " + subscriptionInfo.getSubscriptionId());
}
// Get MCC
// Get MCC (getMccString() is API 29+; use getMcc() on older devices)
try {
String mcc = subscriptionInfo.getMccString();
String mcc = null;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
mcc = subscriptionInfo.getMccString();
} else {
int mccInt = subscriptionInfo.getMcc();
if (mccInt != Integer.MAX_VALUE) {
mcc = String.format("%03d", mccInt);
}
}
if (mcc != null && !mcc.isEmpty()) {
simInfo.setMcc(mcc);
}
@@ -209,9 +217,17 @@ public class TextBeeUtils {
Log.d(TAG, "Could not get MCC for subscription " + subscriptionInfo.getSubscriptionId());
}
// Get MNC
// Get MNC (getMncString() is API 29+; use getMnc() on older devices)
try {
String mnc = subscriptionInfo.getMncString();
String mnc = null;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
mnc = subscriptionInfo.getMncString();
} else {
int mncInt = subscriptionInfo.getMnc();
if (mncInt != Integer.MAX_VALUE) {
mnc = String.valueOf(mncInt);
}
}
if (mnc != null && !mnc.isEmpty()) {
simInfo.setMnc(mnc);
}
@@ -10,6 +10,10 @@ import android.content.Intent;
import com.vernu.sms.activities.SMSFilterActivity;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.text.Editable;
import android.text.TextWatcher;
import android.util.Log;
import android.view.View;
import android.widget.Button;
@@ -49,13 +53,16 @@ public class MainActivity extends AppCompatActivity {
private Context mContext;
private Switch gatewaySwitch, receiveSMSSwitch, stickyNotificationSwitch;
private EditText apiKeyEditText, fcmTokenEditText, deviceIdEditText, deviceNameEditText;
private EditText apiKeyEditText, fcmTokenEditText, deviceIdEditText, deviceNameEditText, smsSendDelayEditText;
private Button registerDeviceBtn, grantSMSPermissionBtn, scanQRBtn, checkUpdatesBtn, configureFilterBtn;
private ImageButton copyDeviceIdImgBtn;
private TextView deviceBrandAndModelTxt, deviceIdTxt, appVersionNameTxt, appVersionCodeTxt;
private RadioGroup defaultSimSlotRadioGroup;
private static final int SCAN_QR_REQUEST_CODE = 49374;
private static final int PERMISSION_REQUEST_CODE = 0;
private static final long SMS_DELAY_SAVE_DEBOUNCE_MS = 3000L;
private final Handler smsDelaySaveHandler = new Handler(Looper.getMainLooper());
private Runnable smsDelaySaveRunnable;
private String deviceId = null;
private static final String TAG = "MainActivity";
@@ -84,6 +91,7 @@ public class MainActivity extends AppCompatActivity {
appVersionCodeTxt = findViewById(R.id.appVersionCodeTxt);
checkUpdatesBtn = findViewById(R.id.checkUpdatesBtn);
configureFilterBtn = findViewById(R.id.configureFilterBtn);
smsSendDelayEditText = findViewById(R.id.smsSendDelayEditText);
deviceIdTxt.setText(deviceId);
deviceIdEditText.setText(deviceId);
@@ -255,6 +263,63 @@ public class MainActivity extends AppCompatActivity {
Intent filterIntent = new Intent(MainActivity.this, SMSFilterActivity.class);
startActivity(filterIntent);
});
// SMS Send Delay setting: save 3 seconds after user stops typing
int currentDelay = SharedPreferenceHelper.getSharedPreferenceInt(
mContext, AppConstants.SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY, AppConstants.DEFAULT_SMS_SEND_DELAY_SECONDS);
smsSendDelayEditText.setText(String.valueOf(currentDelay));
smsDelaySaveRunnable = this::saveSendDelay;
smsSendDelayEditText.addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {}
@Override
public void afterTextChanged(Editable s) {
smsDelaySaveHandler.removeCallbacks(smsDelaySaveRunnable);
smsDelaySaveHandler.postDelayed(smsDelaySaveRunnable, SMS_DELAY_SAVE_DEBOUNCE_MS);
}
});
smsSendDelayEditText.setOnEditorActionListener((v, actionId, event) -> {
smsDelaySaveHandler.removeCallbacks(smsDelaySaveRunnable);
saveSendDelay();
return false;
});
}
private void saveSendDelay() {
String text = smsSendDelayEditText.getText().toString().trim();
if (text.isEmpty()) {
int defaultDelay = AppConstants.DEFAULT_SMS_SEND_DELAY_SECONDS;
smsSendDelayEditText.setText(String.valueOf(defaultDelay));
SharedPreferenceHelper.setSharedPreferenceInt(mContext, AppConstants.SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY, defaultDelay);
Snackbar.make(smsSendDelayEditText, "SMS send delay saved (" + defaultDelay + " sec)", Snackbar.LENGTH_SHORT).show();
return;
}
try {
int value = Integer.parseInt(text);
if (value < 0) {
value = 0;
smsSendDelayEditText.setText("0");
SharedPreferenceHelper.setSharedPreferenceInt(mContext, AppConstants.SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY, 0);
Snackbar.make(smsSendDelayEditText, "Minimum delay is 0 seconds. Saved.", Snackbar.LENGTH_SHORT).show();
} else if (value > 3600) {
value = 3600;
smsSendDelayEditText.setText("3600");
SharedPreferenceHelper.setSharedPreferenceInt(mContext, AppConstants.SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY, 3600);
Snackbar.make(smsSendDelayEditText, "Maximum delay is 3600 seconds. Saved.", Snackbar.LENGTH_SHORT).show();
} else {
SharedPreferenceHelper.setSharedPreferenceInt(mContext, AppConstants.SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY, value);
Snackbar.make(smsSendDelayEditText, "SMS send delay saved (" + value + " sec)", Snackbar.LENGTH_SHORT).show();
}
} catch (NumberFormatException e) {
int defaultDelay = AppConstants.DEFAULT_SMS_SEND_DELAY_SECONDS;
smsSendDelayEditText.setText(String.valueOf(defaultDelay));
SharedPreferenceHelper.setSharedPreferenceInt(mContext, AppConstants.SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY, defaultDelay);
Snackbar.make(smsSendDelayEditText, "Invalid value. Reset to " + defaultDelay + " sec.", Snackbar.LENGTH_SHORT).show();
}
}
private void renderAvailableSimOptions() {
@@ -16,6 +16,7 @@ public class HeartbeatInputDTO {
private String timezone;
private String locale;
private Boolean receiveSMSEnabled;
private Integer smsSendDelaySeconds;
private SimInfoCollectionDTO simInfo;
public HeartbeatInputDTO() {
@@ -141,6 +142,14 @@ public class HeartbeatInputDTO {
this.receiveSMSEnabled = receiveSMSEnabled;
}
public Integer getSmsSendDelaySeconds() {
return smsSendDelaySeconds;
}
public void setSmsSendDelaySeconds(Integer smsSendDelaySeconds) {
this.smsSendDelaySeconds = smsSendDelaySeconds;
}
public SimInfoCollectionDTO getSimInfo() {
return simInfo;
}
@@ -138,6 +138,14 @@ public class HeartbeatHelper {
);
heartbeatInput.setReceiveSMSEnabled(receiveSMSEnabled);
// SMS send delay (device queue)
int smsSendDelaySeconds = SharedPreferenceHelper.getSharedPreferenceInt(
context,
AppConstants.SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY,
AppConstants.DEFAULT_SMS_SEND_DELAY_SECONDS
);
heartbeatInput.setSmsSendDelaySeconds(smsSendDelaySeconds);
// Collect SIM information
SimInfoCollectionDTO simInfoCollection = new SimInfoCollectionDTO();
simInfoCollection.setLastUpdated(System.currentTimeMillis());
@@ -9,20 +9,14 @@ import android.os.Build;
import android.telephony.SubscriptionManager;
import android.util.Log;
import com.vernu.sms.ApiManager;
import com.vernu.sms.AppConstants;
import com.vernu.sms.TextBeeUtils;
import com.vernu.sms.dtos.SMSDTO;
import com.vernu.sms.dtos.SMSForwardResponseDTO;
import com.vernu.sms.receivers.SMSStatusReceiver;
import com.vernu.sms.services.GatewayApiService;
import com.vernu.sms.workers.SMSStatusUpdateWorker;
import java.util.ArrayList;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
public class SMSHelper {
private static final String TAG = "SMSHelper";
@@ -179,25 +173,8 @@ public class SMSHelper {
Log.e(TAG, "Device ID or API key not found");
return;
}
GatewayApiService apiService = ApiManager.getApiService();
Call<SMSForwardResponseDTO> call = apiService.updateSMSStatus(deviceId, apiKey, smsDTO);
call.enqueue(new Callback<SMSForwardResponseDTO>() {
@Override
public void onResponse(Call<SMSForwardResponseDTO> call, Response<SMSForwardResponseDTO> response) {
if (response.isSuccessful()) {
Log.d(TAG, "SMS status updated successfully - ID: " + smsDTO.getSmsId() + ", Status: " + smsDTO.getStatus());
} else {
Log.e(TAG, "Failed to update SMS status. Response code: " + response.code());
}
}
@Override
public void onFailure(Call<SMSForwardResponseDTO> call, Throwable t) {
Log.e(TAG, "API call failed: " + t.getMessage());
}
});
SMSStatusUpdateWorker.enqueueWork(context, deviceId, apiKey, smsDTO);
}
private static PendingIntent createSentPendingIntent(Context context, String smsId, String smsBatchId) {
@@ -7,6 +7,9 @@ import android.content.Intent;
import android.telephony.SmsManager;
import android.util.Log;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import com.vernu.sms.AppConstants;
import com.vernu.sms.dtos.SMSDTO;
import com.vernu.sms.helpers.SharedPreferenceHelper;
@@ -19,6 +22,29 @@ public class SMSStatusReceiver extends BroadcastReceiver {
public static final String SMS_SENT = "SMS_SENT";
public static final String SMS_DELIVERED = "SMS_DELIVERED";
/**
* Resolves a result code to the constant name (e.g. SmsManager.RESULT_ERROR_GENERIC_FAILURE)
* via reflection. Returns null if no matching constant is found.
*/
private static String getResultCodeName(int resultCode) {
for (Class<?> clazz : new Class<?>[]{ SmsManager.class, Activity.class }) {
try {
for (Field field : clazz.getDeclaredFields()) {
if (field.getType() != int.class) continue;
if (!Modifier.isStatic(field.getModifiers()) || !Modifier.isFinal(field.getModifiers())) continue;
if (!field.getName().startsWith("RESULT_")) continue;
field.setAccessible(true);
if (field.getInt(null) == resultCode) {
return clazz.getSimpleName() + "." + field.getName();
}
}
} catch (Exception e) {
Log.w(TAG, "Reflection failed for " + clazz.getSimpleName() + ": " + e.getMessage());
}
}
return null;
}
@Override
public void onReceive(Context context, Intent intent) {
String smsId = intent.getStringExtra("sms_id");
@@ -30,13 +56,13 @@ public class SMSStatusReceiver extends BroadcastReceiver {
smsDTO.setSmsBatchId(smsBatchId);
if (SMS_SENT.equals(action)) {
handleSentStatus(context, getResultCode(), smsDTO);
handleSentStatus(context, intent, getResultCode(), smsDTO);
} else if (SMS_DELIVERED.equals(action)) {
handleDeliveredStatus(context, getResultCode(), smsDTO);
}
}
private void handleSentStatus(Context context, int resultCode, SMSDTO smsDTO) {
private void handleSentStatus(Context context, Intent intent, int resultCode, SMSDTO smsDTO) {
long timestamp = System.currentTimeMillis();
String errorMessage = "";
@@ -47,7 +73,11 @@ public class SMSStatusReceiver extends BroadcastReceiver {
Log.d(TAG, "SMS sent successfully - ID: " + smsDTO.getSmsId());
break;
case SmsManager.RESULT_ERROR_GENERIC_FAILURE:
errorMessage = "Generic failure";
errorMessage = "SMS failed on device. Common causes: no SMS credit on SIM, weak signal, or carrier blocked. Check SIM balance and signal, then try again.";
int radioCode = intent.getIntExtra("errorCode", -1);
if (radioCode != -1) {
errorMessage += " (code " + radioCode + ")";
}
smsDTO.setStatus("FAILED");
smsDTO.setFailedAtInMillis(timestamp);
smsDTO.setErrorCode(String.valueOf(resultCode));
@@ -55,7 +85,7 @@ public class SMSStatusReceiver extends BroadcastReceiver {
Log.e(TAG, "SMS failed to send - ID: " + smsDTO.getSmsId() + ", Error code: " + resultCode + ", Error: " + errorMessage);
break;
case SmsManager.RESULT_ERROR_RADIO_OFF:
errorMessage = "Radio off";
errorMessage = "Mobile radio is off (e.g. airplane mode). Turn off airplane mode and ensure cellular is on.";
smsDTO.setStatus("FAILED");
smsDTO.setFailedAtInMillis(timestamp);
smsDTO.setErrorCode(String.valueOf(resultCode));
@@ -63,7 +93,7 @@ public class SMSStatusReceiver extends BroadcastReceiver {
Log.e(TAG, "SMS failed to send - ID: " + smsDTO.getSmsId() + ", Error code: " + resultCode + ", Error: " + errorMessage);
break;
case SmsManager.RESULT_ERROR_NULL_PDU:
errorMessage = "Null PDU";
errorMessage = "Message could not be sent; invalid format or carrier issue. Try a shorter message or different recipient.";
smsDTO.setStatus("FAILED");
smsDTO.setFailedAtInMillis(timestamp);
smsDTO.setErrorCode(String.valueOf(resultCode));
@@ -71,7 +101,7 @@ public class SMSStatusReceiver extends BroadcastReceiver {
Log.e(TAG, "SMS failed to send - ID: " + smsDTO.getSmsId() + ", Error code: " + resultCode + ", Error: " + errorMessage);
break;
case SmsManager.RESULT_ERROR_NO_SERVICE:
errorMessage = "No service";
errorMessage = "No cellular service. Check signal and try again when you have coverage.";
smsDTO.setStatus("FAILED");
smsDTO.setFailedAtInMillis(timestamp);
smsDTO.setErrorCode(String.valueOf(resultCode));
@@ -79,7 +109,7 @@ public class SMSStatusReceiver extends BroadcastReceiver {
Log.e(TAG, "SMS failed to send - ID: " + smsDTO.getSmsId() + ", Error code: " + resultCode + ", Error: " + errorMessage);
break;
case SmsManager.RESULT_ERROR_LIMIT_EXCEEDED:
errorMessage = "Sending limit exceeded";
errorMessage = "Device/carrier send limit reached (too many SMS in a short time). Wait a few minutes or lower the send rate.";
smsDTO.setStatus("FAILED");
smsDTO.setFailedAtInMillis(timestamp);
smsDTO.setErrorCode(String.valueOf(resultCode));
@@ -87,7 +117,7 @@ public class SMSStatusReceiver extends BroadcastReceiver {
Log.e(TAG, "SMS failed to send - ID: " + smsDTO.getSmsId() + ", Error code: " + resultCode + ", Error: " + errorMessage);
break;
case SmsManager.RESULT_ERROR_SHORT_CODE_NOT_ALLOWED:
errorMessage = "Short code not allowed";
errorMessage = "Short code not allowed on this carrier. Use a full phone number.";
smsDTO.setStatus("FAILED");
smsDTO.setFailedAtInMillis(timestamp);
smsDTO.setErrorCode(String.valueOf(resultCode));
@@ -95,7 +125,7 @@ public class SMSStatusReceiver extends BroadcastReceiver {
Log.e(TAG, "SMS failed to send - ID: " + smsDTO.getSmsId() + ", Error code: " + resultCode + ", Error: " + errorMessage);
break;
case SmsManager.RESULT_ERROR_SHORT_CODE_NEVER_ALLOWED:
errorMessage = "Short code never allowed";
errorMessage = "Short codes are not supported on this carrier. Use a full phone number.";
smsDTO.setStatus("FAILED");
smsDTO.setFailedAtInMillis(timestamp);
smsDTO.setErrorCode(String.valueOf(resultCode));
@@ -103,7 +133,7 @@ public class SMSStatusReceiver extends BroadcastReceiver {
Log.e(TAG, "SMS failed to send - ID: " + smsDTO.getSmsId() + ", Error code: " + resultCode + ", Error: " + errorMessage);
break;
case SmsManager.RESULT_NETWORK_ERROR:
errorMessage = "Network error";
errorMessage = "Network error while sending. Check signal and try again.";
smsDTO.setStatus("FAILED");
smsDTO.setFailedAtInMillis(timestamp);
smsDTO.setErrorCode(String.valueOf(resultCode));
@@ -111,12 +141,13 @@ public class SMSStatusReceiver extends BroadcastReceiver {
Log.e(TAG, "SMS failed to send - ID: " + smsDTO.getSmsId() + ", Error code: " + resultCode + ", Error: " + errorMessage);
break;
default:
errorMessage = "Unknown error";
String codeName = getResultCodeName(resultCode);
errorMessage = codeName != null ? codeName : ("Unknown error (code " + resultCode + ")");
smsDTO.setStatus("FAILED");
smsDTO.setFailedAtInMillis(timestamp);
smsDTO.setErrorCode(String.valueOf(resultCode));
smsDTO.setErrorMessage(errorMessage);
Log.e(TAG, "SMS failed to send - ID: " + smsDTO.getSmsId() + ", Unknown error code: " + resultCode);
Log.e(TAG, "SMS failed to send - ID: " + smsDTO.getSmsId() + ", Error: " + errorMessage);
break;
}
@@ -134,18 +165,19 @@ public class SMSStatusReceiver extends BroadcastReceiver {
Log.d(TAG, "SMS delivered successfully - ID: " + smsDTO.getSmsId());
break;
case Activity.RESULT_CANCELED:
errorMessage = "Delivery canceled";
errorMessage = "Delivery report was canceled (e.g. carrier does not support delivery receipts). Message may still have been delivered.";
smsDTO.setStatus("DELIVERY_FAILED");
smsDTO.setErrorCode(String.valueOf(resultCode));
smsDTO.setErrorMessage(errorMessage);
Log.e(TAG, "SMS delivery failed - ID: " + smsDTO.getSmsId() + ", Error code: " + resultCode + ", Error: " + errorMessage);
break;
default:
errorMessage = "Unknown delivery error";
String deliveryCodeName = getResultCodeName(resultCode);
errorMessage = deliveryCodeName != null ? deliveryCodeName : ("Unknown delivery error (code " + resultCode + ")");
smsDTO.setStatus("DELIVERY_FAILED");
smsDTO.setErrorCode(String.valueOf(resultCode));
smsDTO.setErrorMessage(errorMessage);
Log.e(TAG, "SMS delivery failed - ID: " + smsDTO.getSmsId() + ", Unknown error code: " + resultCode);
Log.e(TAG, "SMS delivery failed - ID: " + smsDTO.getSmsId() + ", Error: " + errorMessage);
break;
}
@@ -16,12 +16,11 @@ import com.google.gson.Gson;
import com.vernu.sms.AppConstants;
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.workers.SmsSendWorker;
import com.vernu.sms.dtos.RegisterDeviceInputDTO;
import com.vernu.sms.dtos.RegisterDeviceResponseDTO;
import com.vernu.sms.ApiManager;
@@ -106,7 +105,8 @@ public class FCMService extends FirebaseMessagingService {
}
/**
* Send SMS to recipients using the provided payload
* Enqueue SMS to recipients via the device-side send queue.
* SIM resolution and rate limiting are handled by SmsSendWorker.
*/
private void sendSMS(SMSPayload smsPayload) {
if (smsPayload == null) {
@@ -114,91 +114,19 @@ public class FCMService extends FirebaseMessagingService {
return;
}
// 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();
if (recipients == null || recipients.length == 0) {
Log.e(TAG, "No recipients found in SMS payload");
return;
}
// Send SMS to each recipient
boolean atLeastOneSent = false;
int sentCount = 0;
int failedCount = 0;
for (String recipient : recipients) {
boolean smsSent;
// 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(),
smsPayload.getSmsId(),
smsPayload.getSmsBatchId(),
this
);
} else {
// Use specific SIM
try {
smsSent = SMSHelper.sendSMSFromSpecificSim(
recipient,
smsPayload.getMessage(),
simSubscriptionId,
smsPayload.getSmsId(),
smsPayload.getSmsBatchId(),
this
);
} catch (Exception e) {
Log.e(TAG, "Error sending SMS from specific SIM: " + e.getMessage());
smsSent = false;
}
}
// Track sent and failed counts
if (smsSent) {
sentCount++;
atLeastOneSent = true;
} else {
failedCount++;
}
SmsSendWorker.enqueue(this, recipient, smsPayload.getMessage(),
smsPayload.getSmsId(), smsPayload.getSmsBatchId(),
smsPayload.getSimSubscriptionId());
}
// Log summary
Log.d(TAG, "SMS sending complete - Batch: " + smsPayload.getSmsBatchId() +
", Sent: " + sentCount + ", Failed: " + failedCount);
Log.d(TAG, "Enqueued " + recipients.length + " SMS for sending - Batch: " + smsPayload.getSmsBatchId());
}
@Override
@@ -1,5 +1,6 @@
package com.vernu.sms.services;
import android.app.ForegroundServiceStartNotAllowedException;
import android.app.*;
import android.content.BroadcastReceiver;
import android.content.Context;
@@ -42,8 +43,14 @@ public class StickyNotificationService extends Service {
if (stickyNotificationEnabled) {
Notification notification = createNotification();
startForeground(1, notification);
Log.i(TAG, "Started foreground service with sticky notification");
try {
startForeground(1, notification);
Log.i(TAG, "Started foreground service with sticky notification");
} catch (ForegroundServiceStartNotAllowedException e) {
Log.w(TAG, "Cannot start foreground from background, stopping service: " + e.getMessage());
stopSelf();
return;
}
} else {
Log.i(TAG, "Sticky notification disabled by user preference");
}
@@ -0,0 +1,113 @@
package com.vernu.sms.workers;
import android.content.Context;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.work.Data;
import androidx.work.ExistingWorkPolicy;
import androidx.work.OneTimeWorkRequest;
import androidx.work.Worker;
import androidx.work.WorkManager;
import androidx.work.WorkerParameters;
import com.vernu.sms.AppConstants;
import com.vernu.sms.TextBeeUtils;
import com.vernu.sms.helpers.SMSHelper;
import com.vernu.sms.helpers.SharedPreferenceHelper;
public class SmsSendWorker extends Worker {
private static final String TAG = "SmsSendWorker";
private static final String QUEUE_NAME = "sms_send_queue";
public static final String KEY_PHONE = "phone";
public static final String KEY_MESSAGE = "message";
public static final String KEY_SMS_ID = "sms_id";
public static final String KEY_SMS_BATCH_ID = "sms_batch_id";
public static final String KEY_SIM_SUBSCRIPTION_ID = "sim_subscription_id";
public SmsSendWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) {
super(context, workerParams);
}
@NonNull
@Override
public Result doWork() {
String phone = getInputData().getString(KEY_PHONE);
String message = getInputData().getString(KEY_MESSAGE);
String smsId = getInputData().getString(KEY_SMS_ID);
String smsBatchId = getInputData().getString(KEY_SMS_BATCH_ID);
int simSubscriptionId = getInputData().getInt(KEY_SIM_SUBSCRIPTION_ID, -1);
if (phone == null || message == null || smsId == null) {
Log.e(TAG, "Missing required parameters");
return Result.failure();
}
Context context = getApplicationContext();
// Resolve SIM: backend-provided > app preference > device default
Integer resolvedSim = resolveSim(context, simSubscriptionId);
if (resolvedSim != null) {
SMSHelper.sendSMSFromSpecificSim(phone, message, resolvedSim, smsId, smsBatchId, context);
} else {
SMSHelper.sendSMS(phone, message, smsId, smsBatchId, context);
}
// Enforce rate limit delay
int delaySeconds = SharedPreferenceHelper.getSharedPreferenceInt(
context, AppConstants.SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY, AppConstants.DEFAULT_SMS_SEND_DELAY_SECONDS);
delaySeconds = Math.max(0, Math.min(delaySeconds, 3600));
if (delaySeconds > 0) {
try {
Thread.sleep(delaySeconds * 1000L);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
return Result.success();
}
private Integer resolveSim(Context context, int backendSimId) {
// Priority 1: backend-provided SIM
if (backendSimId != -1 && TextBeeUtils.isValidSubscriptionId(context, backendSimId)) {
Log.d(TAG, "Using backend-provided SIM subscription ID: " + backendSimId);
return backendSimId;
}
// Priority 2: app preference
int preferredSim = SharedPreferenceHelper.getSharedPreferenceInt(
context, AppConstants.SHARED_PREFS_PREFERRED_SIM_KEY, -1);
if (preferredSim != -1 && TextBeeUtils.isValidSubscriptionId(context, preferredSim)) {
Log.d(TAG, "Using app-preferred SIM subscription ID: " + preferredSim);
return preferredSim;
}
// Priority 3: device default
return null;
}
public static void enqueue(Context context, String phone, String message,
String smsId, String smsBatchId, Integer simSubscriptionId) {
Data inputData = new Data.Builder()
.putString(KEY_PHONE, phone)
.putString(KEY_MESSAGE, message)
.putString(KEY_SMS_ID, smsId)
.putString(KEY_SMS_BATCH_ID, smsBatchId)
.putInt(KEY_SIM_SUBSCRIPTION_ID, simSubscriptionId != null ? simSubscriptionId : -1)
.build();
OneTimeWorkRequest workRequest = new OneTimeWorkRequest.Builder(SmsSendWorker.class)
.setInputData(inputData)
.build();
WorkManager.getInstance(context)
.beginUniqueWork(QUEUE_NAME, ExistingWorkPolicy.APPEND_OR_REPLACE, workRequest)
.enqueue();
Log.d(TAG, "SMS enqueued for sending - ID: " + smsId + ", Phone: " + phone);
}
}
@@ -499,6 +499,57 @@
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" />
<!-- SMS Send Delay Setting -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginBottom="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="SMS Send Delay"
android:textColor="@color/text_primary"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:layout_marginBottom="8dp"
android:text="Delay between each SMS in seconds (0 = no delay, max 3600)"
android:textColor="@color/text_secondary"
android:textSize="14sp"
android:lineSpacingMultiplier="1.2" />
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Delay (seconds)"
app:boxBackgroundColor="@android:color/transparent"
app:boxStrokeColor="?attr/colorPrimary"
app:hintTextColor="?attr/colorPrimary"
app:suffixText="sec"
app:suffixTextColor="@color/text_secondary">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/smsSendDelayEditText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number"
android:textColor="@color/text_primary" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1.5dp"
+10
View File
@@ -24,6 +24,16 @@ MAIL_USER=
MAIL_PASS=
MAIL_FROM=
MAIL_REPLY_TO=
ADMIN_EMAIL=
# Webhook delivery HTTP timeout in milliseconds (default 30000, min 10000, max 60000)
WEBHOOK_DELIVERY_TIMEOUT_MS=30000
# Auto-disable webhook subscriptions with high failure rate (cron runs daily)
WEBHOOK_AUTO_DISABLE_FAILURE_THRESHOLD=50
WEBHOOK_AUTO_DISABLE_LOOKBACK_DAYS=30
# Min failure rate 01 to disable (e.g. 0.50 = 50%; only disable when failures/total >= this)
WEBHOOK_AUTO_DISABLE_MIN_FAILURE_RATE=0.50
# SMS Queue Configuration
USE_SMS_QUEUE=false
+8 -5
View File
@@ -306,11 +306,14 @@ export class BillingService {
}
try {
const discount = await this.polarApi.discounts.get({
id: discountId,
})
if (discount) {
checkoutOptions.discountId = discount.id
let discount = null;
if (discountId) {
discount = await this.polarApi.discounts.get({
id: discountId,
})
if (discount) {
checkoutOptions.discountId = discount.id
}
}
} catch (error) {
console.error('failed to get discount', error)
+7
View File
@@ -468,6 +468,13 @@ export class HeartbeatInputDTO {
})
receiveSMSEnabled?: boolean
@ApiProperty({
type: Number,
required: false,
description: 'SMS send delay in seconds (0-3600), used by device queue',
})
smsSendDelaySeconds?: number
@ApiProperty({ type: SimInfoCollectionDTO, required: false })
simInfo?: SimInfoCollectionDTO
}
+9
View File
@@ -1089,6 +1089,15 @@ const updatedSms = await this.smsModel.findByIdAndUpdate(
updateData.receiveSMSEnabled = input.receiveSMSEnabled
}
// Update smsSendDelaySeconds if provided (clamp 0-3600)
if (input.smsSendDelaySeconds !== undefined) {
const clamped = Math.min(
3600,
Math.max(0, Math.floor(Number(input.smsSendDelaySeconds))),
)
updateData.smsSendDelaySeconds = clamped
}
// Update batteryInfo if provided
if (input.batteryPercentage !== undefined || input.isCharging !== undefined) {
if (input.batteryPercentage !== undefined) {
@@ -48,6 +48,30 @@ export class SmsQueueProcessor {
`SMS Job ${job.id} completed, success: ${response.successCount}, failures: ${response.failureCount}`,
)
// Mark individual SMS records as failed when their FCM push failed
for (let i = 0; i < response.responses.length; i++) {
if (!response.responses[i].success) {
try {
const smsData = JSON.parse(fcmMessages[i].data.smsData)
await this.smsModel.findByIdAndUpdate(smsData.smsId, {
$set: {
status: 'failed',
failedAt: new Date(),
errorCode: 'FCM_DELIVERY_FAILED',
errorMessage:
response.responses[i].error?.message ||
'FCM push notification delivery failed',
},
})
} catch (parseError) {
this.logger.error(
`Failed to mark SMS as failed for FCM message index ${i}`,
parseError,
)
}
}
}
// Update device SMS count
await this.deviceModel
.findByIdAndUpdate(deviceId, {
@@ -77,6 +101,26 @@ export class SmsQueueProcessor {
} catch (error) {
this.logger.error(`Failed to process SMS job ${job.id}`, error)
// Mark all individual SMS in this batch of FCM messages as failed
for (const fcmMessage of fcmMessages) {
try {
const smsData = JSON.parse(fcmMessage.data.smsData)
await this.smsModel.findByIdAndUpdate(smsData.smsId, {
$set: {
status: 'failed',
failedAt: new Date(),
errorCode: 'FCM_SEND_ERROR',
errorMessage: error?.message || 'FCM sendEach call failed',
},
})
} catch (parseError) {
this.logger.error(
'Failed to mark SMS as failed after FCM error',
parseError,
)
}
}
const smsBatch = await this.smsBatchModel.findByIdAndUpdate(
smsBatchId,
{
+6
View File
@@ -4,6 +4,9 @@ import { User } from '../../users/schemas/user.schema'
export type DeviceDocument = Device & Document
/** Default delay between SMS sends (seconds). 5s helps avoid carrier/device throttling. */
export const DEFAULT_SMS_SEND_DELAY_SECONDS = 5
@Schema({ timestamps: true })
export class Device {
_id?: Types.ObjectId
@@ -62,6 +65,9 @@ export class Device {
@Prop({ type: Boolean, default: false })
receiveSMSEnabled: boolean
@Prop({ type: Number, default: DEFAULT_SMS_SEND_DELAY_SECONDS })
smsSendDelaySeconds: number
@Prop({ type: Date })
lastHeartbeat: Date
@@ -162,7 +162,7 @@
purchase. Your upgrade is still available and ready to
unlock powerful features for your SMS workflow.</p>
<!-- Urgency Box -->
<!-- Trust Box -->
<table
role='presentation'
cellpadding='0'
@@ -173,15 +173,16 @@
>
<tr>
<td
style='padding:16px; background:#fef3c7; border-left:4px solid #f59e0b; border-radius:6px;'
style='padding:16px; background:#f0f9ff; border-left:4px solid #0ea5e9; 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>
style='font:600 15px Arial, Helvetica, sans-serif; color:#0c4a6e; margin-bottom:4px;'
>Your checkout session is saved</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>
style='font:14px/1.5 Arial, Helvetica, sans-serif; color:#075985;'
>Complete your upgrade in the next few minutes to
secure your Pro account. Cancel anytime, no
long-term commitment required.</div>
</td>
</tr>
</table>
@@ -219,6 +220,17 @@
>View pricing details →</a>
</div>
<!-- Custom Plan CTA -->
<div style='text-align:center; margin:12px 0;'>
<div
style='font:14px/1.5 Arial, Helvetica, sans-serif; color:#6b7280; margin-bottom:4px;'
>Need a custom plan?</div>
<a
href='mailto:sales@textbee.dev'
style='font:600 14px Arial, Helvetica, sans-serif; color:#EA580C; text-decoration:underline;'
>Contact sales@textbee.dev</a>
</div>
<!-- Support -->
<table
role='presentation'
@@ -4,77 +4,40 @@
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
<title>Account Deletion Request</title>
<style>
body { font-family: 'Helvetica Neue', Arial, sans-serif; line-height: 1.6;
color: #333; max-width: 600px; margin: 0 auto; padding: 20px; } .header {
text-align: center; margin-bottom: 20px; } .logo { max-width: 150px;
margin-bottom: 10px; } h1 { color: #dc2626; margin-bottom: 20px; }
.content { background-color: #f9fafb; border-radius: 8px; padding: 20px;
margin-bottom: 20px; } .message-details { background-color: #ffffff;
border-left: 4px solid #dc2626; padding: 15px; margin: 15px 0;
border-radius: 4px; } .contact-info { margin-top: 20px; padding-top: 15px;
border-top: 1px solid #e5e7eb; } .field-label { font-weight: bold; color:
#4b5563; margin-bottom: 5px; } .footer { text-align: center; font-size:
14px; color: #6b7280; margin-top: 30px; padding-top: 20px; border-top: 1px
solid #e5e7eb; } .important-notice { background-color: #fee2e2;
border-left: 4px solid #dc2626; padding: 15px; margin: 15px 0;
border-radius: 4px; } .cancel-notice { background-color: #e0f2fe; border:
2px solid #0284c7; padding: 15px; margin: 20px 0; border-radius: 4px;
text-align: center; } .cancel-notice h3 { color: #0284c7; margin-top: 0; }
.cancel-action { font-weight: bold; font-size: 16px; } .cancel-button {
display: inline-block; margin-top: 10px; padding: 8px 16px;
background-color: #0284c7; color: white; border-radius: 4px;
text-decoration: none; font-weight: bold; }
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
h1 { color: #dc2626; }
.notice { background: #fee2e2; border-left: 4px solid #dc2626; padding: 15px; margin: 15px 0; }
.cancel { background: #e0f2fe; border: 2px solid #0284c7; padding: 15px; margin: 20px 0; text-align: center; }
.footer { text-align: center; font-size: 14px; color: #6b7280; margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb; }
</style>
</head>
<body>
<div class='header'>
{{!-- <img src="{{appLogoUrl}}" alt="TextBee Logo" class="logo"> --}}
<h1>Account Deletion Request</h1>
<h1>Account Deletion Request</h1>
<p>Hello {{name}},</p>
<p>We have received your request to delete your TextBee account. We're sorry to see you go.</p>
<div class='notice'>
<p><strong>Important:</strong> Your account has been marked for deletion and will be processed within 7 days. During this period:</p>
<ul>
<li>You can still log in and access your account until the deletion is completed</li>
<li>After the deletion is complete, all your data will be permanently removed</li>
<li>This action cannot be undone once processed</li>
</ul>
</div>
<div class='content'>
<p>Hello {{name}},</p>
<p><strong>Reason for deletion:</strong> {{#if message}}{{message}}{{else}}No reason provided{{/if}}</p>
<p>We have received your request to delete your TextBee account. We're
sorry to see you go.</p>
<p><strong>Account:</strong> {{name}} ({{email}})</p>
<div class='important-notice'>
<p><strong>Important:</strong>
Your account has been marked for deletion and will be processed within
7 days. During this period:</p>
<ul>
<li>You can still log in and access your account until the deletion is
completed</li>
<li>After the deletion is complete, all your data will be permanently
removed</li>
<li>This action cannot be undone once processed</li>
</ul>
</div>
<div class='message-details'>
<div class='field-label'>Reason for deletion:</div>
<p>{{#if message}}{{message}}{{else}}No reason provided{{/if}}</p>
</div>
<div class='contact-info'>
<div class='field-label'>Account Information:</div>
<p>Name: {{name}}</p>
<p>Email: {{email}}</p>
</div>
<div class='cancel-notice'>
<h3>Changed Your Mind?</h3>
<p class='cancel-action'>If you didn't request this deletion or want to
keep your account, you can easily cancel this request!</p>
<p>Simply reply to this email as soon as possible and we'll immediately
stop the deletion process.</p>
<p>Your account and all your data will remain intact. No further action
will be needed.</p>
</div>
<div class='cancel'>
<h3 style='color: #0284c7; margin-top: 0;'>Changed Your Mind?</h3>
<p>If you didn't request this deletion or want to keep your account, simply reply to this email and we'll immediately stop the deletion process.</p>
</div>
<div class='footer'>
<p>&copy; {{currentYear}} textBee.dev.</p>
<p>&copy; {{currentYear}} textbee.dev.</p>
</div>
</body>
</html>
</html>
@@ -4,58 +4,32 @@
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
<title>Support Request Confirmation</title>
<style>
body { font-family: 'Helvetica Neue', Arial, sans-serif; line-height: 1.6;
color: #333; max-width: 600px; margin: 0 auto; padding: 20px; } .header {
text-align: center; margin-bottom: 20px; } .logo { max-width: 150px;
margin-bottom: 10px; } h1 { color: #2563eb; margin-bottom: 20px; }
.content { background-color: #f9fafb; border-radius: 8px; padding: 20px;
margin-bottom: 20px; } .message-details { background-color: #ffffff;
border-left: 4px solid #2563eb; padding: 15px; margin: 15px 0;
border-radius: 4px; } .contact-info { margin-top: 20px; padding-top: 15px;
border-top: 1px solid #e5e7eb; } .field-label { font-weight: bold; color:
#4b5563; margin-bottom: 5px; } .footer { text-align: center; font-size:
14px; color: #6b7280; margin-top: 30px; padding-top: 20px; border-top: 1px
solid #e5e7eb; }
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
h1 { color: #2563eb; }
.details { background: #ffffff; border-left: 4px solid #2563eb; padding: 15px; margin: 15px 0; }
.footer { text-align: center; font-size: 14px; color: #6b7280; margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb; }
</style>
</head>
<body>
<div class='header'>
{{!-- <img src="{{appLogoUrl}}" alt="TextBee Logo" class="logo"> --}}
<h1>Support Request Submitted</h1>
<h1>Support Request Submitted</h1>
<p>Hello {{name}},</p>
<p>Thank you for contacting our support team. We have received your message and will get back to you as soon as possible.</p>
<div class='details'>
<p><strong>Category:</strong> {{category}}</p>
<p><strong>Your Message:</strong></p>
<p>{{message}}</p>
</div>
<div class='content'>
<p>Hello {{name}},</p>
<p><strong>Your Contact Information:</strong></p>
<p>Name: {{name}}<br />Email: {{email}}<br />Phone: {{#if phone}}{{phone}}{{else}}Not provided{{/if}}</p>
<p>Thank you for contacting our support team. We have received your
message and will get back to you as soon as possible.</p>
<div class='message-details'>
<div class='field-label'>Category:</div>
<p>{{category}}</p>
<div class='field-label'>Your Message:</div>
<p>{{message}}</p>
</div>
<div class='contact-info'>
<div class='field-label'>Your Contact Information:</div>
<p>Name: {{name}}</p>
<p>Email: {{email}}</p>
{{#if phone}}
<p>Phone: {{phone}}</p>
{{else}}
<p>Phone: Not provided</p>
{{/if}}
</div>
<p>we will review your request and respond to the email address you
provided. If you have any additional information to share, please reply
to this email.</p>
</div>
<p>We will review your request and respond to the email address you provided. If you have any additional information to share, please reply to this email.</p>
<div class='footer'>
<p>&copy; {{currentYear}} textBee.dev.</p>
<p>&copy; {{currentYear}} textbee.dev.</p>
</div>
</body>
</html>
</html>
@@ -0,0 +1,58 @@
<html>
<head>
<meta charset='utf-8' />
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
<title>{{title}} {{runAt}}</title>
<style>
body { font-family: Arial, sans-serif; line-height: 1.5; color: #333; margin: 0; padding: 0; }
.container { max-width: 900px; margin: 0 auto; padding: 20px; }
.header { padding: 16px 0; border-bottom: 1px solid #eee; }
.title { font-size: 18px; font-weight: bold; }
.meta { font-size: 12px; color: #666; margin-top: 4px; }
table { width: 100%; border-collapse: collapse; margin: 16px 0; font-size: 13px; }
th, td { padding: 10px; text-align: left; border-bottom: 1px solid #ddd; }
th { background-color: #f5f5f5; font-weight: 600; }
.url { word-break: break-all; max-width: 240px; }
.footer { font-size: 12px; color: #777; margin-top: 24px; padding-top: 16px; border-top: 1px solid #eee; }
</style>
</head>
<body>
<div class='container'>
<div class='header'>
<div class='title'>{{title}}</div>
<div class='meta'>Run at {{runAt}} · {{count}} subscription(s) auto-disabled</div>
</div>
<table>
<thead>
<tr>
<th>Subscription ID</th>
<th>Delivery URL</th>
<th>Failed</th>
<th>Success</th>
<th>Total</th>
<th>Failure rate %</th>
<th>Period (days)</th>
<th>User name</th>
<th>User email</th>
</tr>
</thead>
<tbody>
{{#each disabledList}}
<tr>
<td>{{this.subscriptionId}}</td>
<td class='url'>{{this.deliveryUrl}}</td>
<td>{{this.failureCount}}</td>
<td>{{this.successCount}}</td>
<td>{{this.totalAttempts}}</td>
<td>{{this.failureRatePercent}}</td>
<td>{{this.lookbackDays}}</td>
<td>{{this.userName}}</td>
<td>{{this.userEmail}}</td>
</tr>
{{/each}}
</tbody>
</table>
<div class='footer'>{{brandName}} Webhook auto-disable cron</div>
</div>
</body>
</html>
@@ -0,0 +1,88 @@
<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'>Your webhook was paused due to delivery failures. Re-enable it when ready.</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 8px 0;'>We've temporarily disabled this webhook so repeated failures don't affect your account.</p>
<p style='margin:0 0 8px 0;'>In the last {{lookbackDays}} days: {{failureCount}} failed, {{successCount}} succeeded ({{totalAttempts}} total) — {{failureRatePercent}}% failure rate.</p>
<p style='margin:0 0 16px 0;'>Fix your endpoint, then re-enable the webhook in the dashboard when you're ready.</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 webhooks 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>
@@ -0,0 +1,29 @@
import { Process, Processor } from '@nestjs/bull'
import { Job } from 'bull'
import { WebhookService } from '../webhook.service'
import { Logger } from '@nestjs/common'
@Processor('webhook-delivery')
export class WebhookQueueProcessor {
private readonly logger = new Logger(WebhookQueueProcessor.name)
constructor(private readonly webhookService: WebhookService) {}
@Process({
name: 'deliver-webhook',
concurrency: 10,
})
async handleWebhookDelivery(job: Job<{ notificationId: string }>) {
this.logger.debug(`Processing webhook delivery job ${job.id} for notification ${job.data.notificationId}`)
try {
await this.webhookService.attemptWebhookDelivery(job.data.notificationId)
} catch (error) {
this.logger.error(
`Failed to process webhook delivery job ${job.id} for notification ${job.data.notificationId}`,
error,
)
throw error
}
}
}
@@ -0,0 +1,29 @@
import { Injectable, Logger } from '@nestjs/common'
import { InjectQueue } from '@nestjs/bull'
import { Queue } from 'bull'
@Injectable()
export class WebhookQueueService {
private readonly logger = new Logger(WebhookQueueService.name)
constructor(
@InjectQueue('webhook-delivery')
private readonly webhookQueue: Queue,
) {}
async addWebhookDeliveryJob(notificationId: string) {
this.logger.debug(`Adding webhook delivery job for notification ${notificationId}`)
await this.webhookQueue.add(
'deliver-webhook',
{
notificationId,
},
{
attempts: 1,
removeOnComplete: false,
removeOnFail: false,
},
)
}
}
@@ -35,6 +35,21 @@ export class WebhookNotification {
@Prop({ type: Date })
deliveryAttemptAbortedAt: Date
@Prop({ type: String })
idempotencyKey?: string
@Prop({ type: String, enum: ['retryable', 'non-retryable'] })
errorType?: string
@Prop({ type: Number })
httpStatusCode?: number
@Prop({ type: String, maxlength: 1000 })
responseBody?: string
@Prop({ type: String })
deliveryUrl?: string
}
export const WebhookNotificationSchema =
@@ -40,6 +40,12 @@ export class WebhookSubscription {
@Prop({ type: Date })
lastDeliveryFailureAt: Date
@Prop({
type: [{ at: { type: Date }, text: { type: String } }],
default: [],
})
notes: { at: Date; text: string }[]
}
export const WebhookSubscriptionSchema =
+14 -1
View File
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import { BullModule } from '@nestjs/bull'
import { WebhookController } from './webhook.controller'
import { WebhookService } from './webhook.service'
import {
@@ -12,6 +13,9 @@ import {
} from './schemas/webhook-notification.schema'
import { AuthModule } from 'src/auth/auth.module'
import { UsersModule } from 'src/users/users.module'
import { MailModule } from 'src/mail/mail.module'
import { WebhookQueueService } from './queue/webhook-queue.service'
import { WebhookQueueProcessor } from './queue/webhook-queue.processor'
@Module({
imports: [
@@ -25,11 +29,20 @@ import { UsersModule } from 'src/users/users.module'
schema: WebhookNotificationSchema,
},
]),
BullModule.registerQueue({
name: 'webhook-delivery',
defaultJobOptions: {
attempts: 1,
removeOnComplete: false,
removeOnFail: false,
},
}),
AuthModule,
UsersModule,
MailModule,
],
controllers: [WebhookController],
providers: [WebhookService],
providers: [WebhookService, WebhookQueueService, WebhookQueueProcessor],
exports: [MongooseModule, WebhookService],
})
export class WebhookModule {}
+371 -51
View File
@@ -13,10 +13,12 @@ import {
import axios from 'axios'
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'
import { SMS } from '../gateway/schemas/sms.schema'
import { WebhookQueueService } from './queue/webhook-queue.service'
import { MailService } from '../mail/mail.service'
import { UsersService } from '../users/users.service'
@Injectable()
export class WebhookService {
@@ -25,6 +27,9 @@ export class WebhookService {
private webhookSubscriptionModel: Model<WebhookSubscriptionDocument>,
@InjectModel(WebhookNotification.name)
private webhookNotificationModel: Model<WebhookNotificationDocument>,
private webhookQueueService: WebhookQueueService,
private mailService: MailService,
private usersService: UsersService,
) {}
async findOne({ user, webhookId }) {
@@ -320,14 +325,20 @@ export class WebhookService {
throw new HttpException('Invalid event type', HttpStatus.BAD_REQUEST)
}
let payload: Record<string, any>= {
// Generate idempotency key
const idempotencyKey = uuidv4()
// Store delivery URL snapshot for debugging
const deliveryUrlSnapshot = webhookSubscription.deliveryUrl
let payload: Record<string, any> = {
smsId: sms._id,
message: sms.message,
deviceId: sms.device,
webhookSubscriptionId: webhookSubscription._id,
webhookEvent: event,
};
idempotencyKey,
}
switch (event) {
case WebhookEvent.MESSAGE_RECEIVED:
@@ -335,8 +346,8 @@ export class WebhookService {
...payload,
sender: sms.sender,
receivedAt: sms.receivedAt,
};
break;
}
break
case WebhookEvent.MESSAGE_DELIVERED:
payload = {
@@ -346,8 +357,8 @@ export class WebhookService {
recipient: sms.recipient,
sentAt: sms.sentAt,
deliveredAt: sms.deliveredAt,
};
break;
}
break
case WebhookEvent.MESSAGE_SENT:
payload = {
@@ -356,8 +367,8 @@ export class WebhookService {
status: sms.status,
recipient: sms.recipient,
sentAt: sms.sentAt,
};
break;
}
break
case WebhookEvent.MESSAGE_FAILED:
payload = {
@@ -368,8 +379,8 @@ export class WebhookService {
errorCode: sms.errorCode,
errorMessage: sms.errorMessage,
failedAt: sms.failedAt,
};
break;
}
break
case WebhookEvent.UNKNOWN_STATE:
payload = {
@@ -377,26 +388,37 @@ export class WebhookService {
smsBatchId: sms.smsBatch,
status: sms.status,
recipient: sms.recipient,
};
break;
}
break
}
const webhookNotification = await this.webhookNotificationModel.create({
webhookSubscription: webhookSubscription._id,
event,
payload,
sms,
})
const webhookNotification = await this.webhookNotificationModel.create({
webhookSubscription: webhookSubscription._id,
event,
payload,
sms,
idempotencyKey,
deliveryUrl: deliveryUrlSnapshot,
})
await this.attemptWebhookDelivery(webhookNotification)
// Queue job instead of synchronous delivery
await this.webhookQueueService.addWebhookDeliveryJob(
webhookNotification._id.toString(),
)
}
private async attemptWebhookDelivery(
webhookNotification: WebhookNotificationDocument,
) {
async attemptWebhookDelivery(notificationId: string) {
const now = new Date()
const webhookSubscriptionId = webhookNotification.webhookSubscription
const webhookNotification = await this.webhookNotificationModel.findById(
notificationId,
)
if (!webhookNotification) {
console.log(`Webhook notification not found for ${notificationId}`)
return
}
const webhookSubscriptionId = webhookNotification.webhookSubscription
const webhookSubscription = await this.webhookSubscriptionModel.findById(
webhookSubscriptionId,
)
@@ -423,39 +445,99 @@ export class WebhookService {
.update(JSON.stringify(webhookNotification.payload))
.digest('hex')
let httpStatusCode: number | undefined
let responseBody: string | undefined
let errorType: 'retryable' | 'non-retryable' | undefined
const deliveryTimeoutMs = Math.min(
60000,
Math.max(10000, parseInt(process.env.WEBHOOK_DELIVERY_TIMEOUT_MS ?? '30000', 10) || 30000),
)
try {
await axios.post(deliveryUrl, webhookNotification.payload, {
const response = await axios.post(deliveryUrl, webhookNotification.payload, {
headers: {
'X-Signature': signature,
},
timeout: 10000,
timeout: deliveryTimeoutMs,
})
httpStatusCode = response.status
responseBody = typeof response.data === 'string'
? response.data.substring(0, 1000)
: JSON.stringify(response.data).substring(0, 1000)
webhookNotification.deliveryAttemptCount += 1
webhookNotification.lastDeliveryAttemptAt = now
webhookNotification.nextDeliveryAttemptAt = this.getNextDeliveryAttemptAt(
webhookNotification.deliveryAttemptCount,
)
webhookNotification.deliveredAt = now
webhookNotification.httpStatusCode = httpStatusCode
webhookNotification.responseBody = responseBody
await webhookNotification.save()
webhookSubscription.successfulDeliveryCount += 1
webhookSubscription.lastDeliverySuccessAt = now
} catch (e) {
console.log(
`Failed to deliver webhook notification: ID ${webhookNotification._id}, status code: ${e.response?.status}, message: ${e.message}`,
await this.webhookSubscriptionModel.updateOne(
{ _id: webhookSubscriptionId },
{
$inc: { successfulDeliveryCount: 1, deliveryAttemptCount: 1 },
$set: { lastDeliverySuccessAt: now },
},
)
} catch (e) {
// Classify error type
if (e.response?.status) {
httpStatusCode = e.response.status
responseBody = typeof e.response.data === 'string'
? e.response.data.substring(0, 1000)
: JSON.stringify(e.response.data || {}).substring(0, 1000)
// 4xx errors are non-retryable, 5xx are retryable
if (e.response.status >= 400 && e.response.status < 500) {
errorType = 'non-retryable'
} else if (e.response.status >= 500) {
errorType = 'retryable'
}
} else {
// Network/timeout errors are retryable
errorType = 'retryable'
responseBody = e.message?.substring(0, 1000)
}
webhookNotification.deliveryAttemptCount += 1
webhookNotification.lastDeliveryAttemptAt = now
webhookNotification.nextDeliveryAttemptAt = this.getNextDeliveryAttemptAt(
webhookNotification.deliveryAttemptCount,
)
webhookNotification.httpStatusCode = httpStatusCode
webhookNotification.responseBody = responseBody
webhookNotification.errorType = errorType
// For 4xx errors, mark as abandoned after 3rd attempt
if (errorType === 'non-retryable' && webhookNotification.deliveryAttemptCount >= 3) {
webhookNotification.deliveryAttemptAbortedAt = now
webhookNotification.nextDeliveryAttemptAt = undefined
} else if (errorType === 'retryable' && webhookNotification.deliveryAttemptCount < 10) {
// For retryable errors, schedule next attempt
webhookNotification.nextDeliveryAttemptAt = this.getNextDeliveryAttemptAt(
webhookNotification.deliveryAttemptCount,
)
} else {
// Max attempts reached
webhookNotification.deliveryAttemptAbortedAt = now
webhookNotification.nextDeliveryAttemptAt = undefined
}
await webhookNotification.save()
webhookSubscription.deliveryFailureCount += 1
webhookSubscription.lastDeliveryFailureAt = now
} finally {
webhookSubscription.deliveryAttemptCount += 1
await webhookSubscription.save()
await this.webhookSubscriptionModel.updateOne(
{ _id: webhookSubscriptionId },
{
$inc: { deliveryFailureCount: 1, deliveryAttemptCount: 1 },
$set: { lastDeliveryFailureAt: now },
},
)
console.log(
`Failed to deliver webhook notification: ID ${webhookNotification._id}, status code: ${httpStatusCode}, error type: ${errorType}, message: ${e.message}`,
)
}
}
@@ -483,30 +565,268 @@ export class WebhookService {
return new Date(Date.now() + delayInMinutes * 60 * 1000)
}
// Check for notifications that need to be delivered every 3 minutes
@Cron('0 */3 * * * *', {
disabled: process.env.NODE_ENV !== 'production',
})
// Check for notifications that need to be delivered every 5 minutes
@Cron('0 */5 * * * *')
async checkForNotificationsToDeliver() {
const now = new Date()
const notifications = await this.webhookNotificationModel
.find({
nextDeliveryAttemptAt: { $lte: now },
const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000)
const oneMonthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
// Mark notifications older than one month as aborted so they are no longer retried
await this.webhookNotificationModel.updateMany(
{
nextDeliveryAttemptAt: { $lte: fiveMinutesAgo },
deliveredAt: null,
deliveryAttemptCount: { $lt: 10 },
deliveryAttemptAbortedAt: null,
createdAt: { $lt: oneMonthAgo },
},
{ $set: { deliveryAttemptAbortedAt: now } },
)
const notifications = await this.webhookNotificationModel
.find({
nextDeliveryAttemptAt: { $lte: fiveMinutesAgo },
deliveredAt: null,
deliveryAttemptCount: { $lt: 10 },
deliveryAttemptAbortedAt: null,
createdAt: { $gte: oneMonthAgo },
})
.sort({ nextDeliveryAttemptAt: 1 })
.limit(30)
.limit(200)
if (notifications.length === 0) {
return
}
console.log(`delivering ${notifications.length} webhook notifications`)
console.log(`Queueing ${notifications.length} webhook notifications for retry`)
for (const notification of notifications) {
await this.attemptWebhookDelivery(notification)
await this.webhookQueueService.addWebhookDeliveryJob(
notification._id.toString(),
)
}
}
private getAutoDisableConfig(): {
threshold: number
lookbackDays: number
minFailureRate: number
} {
const threshold = Math.max(
1,
parseInt(process.env.WEBHOOK_AUTO_DISABLE_FAILURE_THRESHOLD ?? '50', 10) || 50,
)
const lookbackDays = Math.max(
1,
Math.min(
365,
parseInt(process.env.WEBHOOK_AUTO_DISABLE_LOOKBACK_DAYS ?? '30', 10) || 30,
),
)
const minFailureRate = Math.min(
1,
Math.max(
0.01,
parseFloat(process.env.WEBHOOK_AUTO_DISABLE_MIN_FAILURE_RATE ?? '0.50') || 0.5,
),
)
return { threshold, lookbackDays, minFailureRate }
}
@Cron('0 6 * * *')
async autoDisableSubscriptionsWithHighFailureRate() {
const { threshold, lookbackDays, minFailureRate } = this.getAutoDisableConfig()
const now = new Date()
const since = new Date(now.getTime() - lookbackDays * 24 * 60 * 60 * 1000)
const twentyFourHoursAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000)
const subscriptionCounts = await this.webhookNotificationModel.aggregate<{
_id: mongoose.Types.ObjectId
count: number
}>([
{
$addFields: {
_finalizedAt: {
$ifNull: ['$lastDeliveryAttemptAt', '$createdAt'],
},
},
},
{
$match: {
deliveredAt: null,
_finalizedAt: { $gte: since },
$or: [
{ deliveryAttemptAbortedAt: { $ne: null } },
{ deliveryAttemptCount: { $gte: 10 } },
],
},
},
{ $group: { _id: '$webhookSubscription', count: { $sum: 1 } } },
{ $match: { count: { $gte: threshold } } },
])
if (subscriptionCounts.length === 0) {
return
}
const subscriptionIds = subscriptionCounts.map((s) => s._id)
const failureCountBySubscriptionId = new Map(
subscriptionCounts.map((s) => [s._id.toString(), s.count]),
)
const successCounts = await this.webhookNotificationModel.aggregate<{
_id: mongoose.Types.ObjectId
count: number
}>([
{
$match: {
webhookSubscription: { $in: subscriptionIds },
deliveredAt: { $ne: null, $gte: since },
},
},
{ $group: { _id: '$webhookSubscription', count: { $sum: 1 } } },
])
const successCountBySubscriptionId = new Map(
successCounts.map((s) => [s._id.toString(), s.count]),
)
const subscriptionsToDisable: { subscriptionId: string; failureCount: number; successCount: number; totalAttempts: number; failureRatePercent: number }[] = []
for (const s of subscriptionCounts) {
const sid = s._id.toString()
const failureCount = failureCountBySubscriptionId.get(sid) ?? 0
const successCount = successCountBySubscriptionId.get(sid) ?? 0
const totalAttempts = failureCount + successCount
const failureRate = totalAttempts > 0 ? failureCount / totalAttempts : 0
if (failureRate >= minFailureRate) {
const failureRatePercent = Math.round(failureRate * 100)
subscriptionsToDisable.push({
subscriptionId: sid,
failureCount,
successCount,
totalAttempts,
failureRatePercent,
})
}
}
if (subscriptionsToDisable.length === 0) {
return
}
const subscriptionIdSet = new Set(subscriptionsToDisable.map((s) => s.subscriptionId))
const activeSubscriptions = await this.webhookSubscriptionModel.find({
_id: { $in: subscriptionIds.filter((id) => subscriptionIdSet.has(id.toString())) },
isActive: true,
})
const ctaUrlBase = process.env.FRONTEND_URL || 'https://app.textbee.dev'
const disabledInThisRun: {
subscriptionId: string
deliveryUrl: string
failureCount: number
successCount: number
totalAttempts: number
failureRatePercent: number
lookbackDays: number
userName: string
userEmail: string
}[] = []
for (const subscription of activeSubscriptions) {
const stats = subscriptionsToDisable.find(
(s) => s.subscriptionId === subscription._id.toString(),
)
if (!stats) continue
if (subscription?.lastDeliverySuccessAt >= twentyFourHoursAgo) {
continue
}
const { failureCount, successCount, totalAttempts, failureRatePercent } = stats
const noteText = `Auto-disabled: ${failureCount} failed and ${successCount} succeeded (${totalAttempts} total) in the last ${lookbackDays} days — failure rate ${failureRatePercent}%. Re-enable in dashboard when your endpoint is ready.`
const noteEntry = { at: new Date(), text: noteText }
const result = await this.webhookSubscriptionModel.updateOne(
{ _id: subscription._id, isActive: true },
{
$set: { isActive: false },
$push: { notes: noteEntry },
},
)
if (result.modifiedCount === 0) {
continue
}
const user = await this.usersService.findOne({
_id: subscription.user,
})
disabledInThisRun.push({
subscriptionId: subscription._id.toString(),
deliveryUrl: subscription.deliveryUrl ?? '',
failureCount,
successCount,
totalAttempts,
failureRatePercent,
lookbackDays,
userName: user?.name ?? '—',
userEmail: user?.email ?? '—',
})
if (!user?.email) {
console.log(
`Webhook subscription ${subscription._id} auto-disabled but no user/email to notify`,
)
continue
}
try {
await this.mailService.sendEmailFromTemplate({
to: user.email,
subject: 'Your webhook was paused textbee',
template: 'webhook-subscription-disabled',
context: {
name: user.name?.split(' ')?.[0] || 'there',
title: 'Your webhook was paused',
failureCount,
successCount,
totalAttempts,
failureRatePercent,
lookbackDays,
ctaUrl: `${ctaUrlBase}/dashboard/account`,
ctaLabel: 'Re-enable in dashboard',
brandName: 'textbee.dev',
},
})
} catch (e) {
console.log(
`Failed to send webhook-disabled email to ${user.email}:`,
e,
)
}
}
const adminEmail = process.env.ADMIN_EMAIL
if (disabledInThisRun.length > 0 && adminEmail) {
const runAt = now.toISOString()
try {
await this.mailService.sendEmailFromTemplate({
to: adminEmail,
subject: `Webhook auto-disable: ${disabledInThisRun.length} subscription(s) ${runAt.slice(0, 10)}`,
template: 'webhook-auto-disable-admin-summary',
context: {
title: 'Webhook auto-disable summary',
runAt,
count: disabledInThisRun.length,
disabledList: disabledInThisRun,
brandName: 'textbee.dev',
},
})
} catch (e) {
console.log(`Failed to send webhook auto-disable admin summary to ${adminEmail}:`, e)
}
}
}
}
@@ -6,6 +6,20 @@ import { useQuery } from '@tanstack/react-query'
import Link from 'next/link'
import { useMemo } from 'react'
const DISCOUNT_CODE_FALLBACK = null
const DISCOUNT_PERCENTAGE_FALLBACK = null
const envDiscountCode = process.env.NEXT_PUBLIC_DISCOUNT_CODE?.trim()
const envDiscountPercentage = process.env.NEXT_PUBLIC_DISCOUNT_PERCENTAGE?.trim()
const discountCode = (envDiscountCode !== undefined && envDiscountCode !== '')
? envDiscountCode
: DISCOUNT_CODE_FALLBACK
const discountPercentage = (envDiscountPercentage !== undefined && envDiscountPercentage !== '')
? envDiscountPercentage
: DISCOUNT_PERCENTAGE_FALLBACK
const isDiscountEnabled = discountCode !== null && discountCode !== '' && discountPercentage !== null && discountPercentage !== ''
export default function UpgradeToProAlert() {
const {
data: currentSubscription,
@@ -43,7 +57,7 @@ export default function UpgradeToProAlert() {
urgency: 'warning'
}
} else {
const ctaMessages = [
const allCtaMessages = [
"Upgrade to Pro for exclusive features and benefits!",
"Offer: You are eligible for a 30% discount when upgrading to Pro!",
"Unlock premium features with our Pro plan today!",
@@ -51,7 +65,7 @@ export default function UpgradeToProAlert() {
"Pro users get priority support and advanced features!",
"Limited time offer: Upgrade to Pro and save 30%!",
]
const buttonTexts = [
const allButtonTexts = [
"Get Pro Now!",
"Upgrade Today!",
"Go Pro!",
@@ -59,12 +73,36 @@ export default function UpgradeToProAlert() {
"Claim Your Discount!",
"Upgrade & Save!",
]
// Filter out discount-related messages if discount is not enabled
const ctaMessages = isDiscountEnabled
? allCtaMessages
: allCtaMessages.filter(
(msg) =>
!msg.toLowerCase().includes('discount') &&
!msg.toLowerCase().includes('offer') &&
!msg.toLowerCase().includes('save') &&
!msg.includes('30%')
)
const buttonTexts = isDiscountEnabled
? allButtonTexts
: allButtonTexts.filter(
(text) =>
!text.toLowerCase().includes('discount') &&
!text.toLowerCase().includes('save')
)
const randomIndex = Math.floor(Math.random() * ctaMessages.length)
const subMessage = isDiscountEnabled
? `Use discount code ${discountCode} at checkout for a ${discountPercentage}% discount!`
: "Unlock premium features, priority support, and advanced capabilities with Pro!"
return {
bgColor: 'bg-gradient-to-r from-purple-500 to-pink-500',
message: ctaMessages[randomIndex],
subMessage: `Use discount code SAVE30P at checkout for a 30% discount!`,
subMessage,
buttonText: buttonTexts[randomIndex],
buttonColor: 'bg-red-500 text-white hover:bg-red-600 border-red-500',
urgency: 'normal'
@@ -87,8 +125,8 @@ export default function UpgradeToProAlert() {
{alertConfig.message}
</span>
<span className='w-full sm:flex-1 text-center sm:text-left text-xs md:text-sm'>
{alertConfig.urgency === 'normal' ? (
<>Use discount code <strong className="text-yellow-200">SAVE30P</strong> at checkout for a 30% discount!</>
{alertConfig.urgency === 'normal' && isDiscountEnabled ? (
<>Use discount code <strong className="text-yellow-200">{discountCode}</strong> at checkout for a {discountPercentage}% discount!</>
) : (
alertConfig.subMessage
)}
+1 -1
View File
@@ -53,7 +53,7 @@ export default function DashboardLayout({
<div className='space-y-2 p-4'>
<VerifyEmailAlert />
<AccountDeletionAlert />
{/* <UpgradeToProAlert /> */}
<UpgradeToProAlert />
{/* <BlackFridayModal /> */}
</div>
{children}
+2
View File
@@ -7,6 +7,7 @@ import LayoutWrapper from './layout-wrapper'
import Analytics from '@/components/shared/analytics'
import { Toaster } from '@/components/ui/toaster'
import Footer from '@/components/shared/footer'
import SupportHQWidget from '@/components/shared/support-hq-widget'
export default async function RootLayout({ children }: PropsWithChildren) {
const session: Session | null = await getServerSession(authOptions as any)
@@ -18,6 +19,7 @@ export default async function RootLayout({ children }: PropsWithChildren) {
<main className='min-h-[80vh]'>{children}</main>
<Analytics user={session?.user} />
<Footer />
<SupportHQWidget />
<Toaster />
</LayoutWrapper>
</>
@@ -0,0 +1,34 @@
'use client'
import { useSession } from 'next-auth/react'
import React, { useEffect } from 'react'
export default function SupportHQWidget() {
const { data: session } = useSession()
useEffect(() => {
const script = document.createElement('script')
script.src = 'https://cdn.supporthq.app/widget/latest/supporthq-widget.js'
script.async = true
// @ts-ignore
script.onload = () => window.SupportHQWidget?.init({
projectId: process.env.NEXT_PUBLIC_SUPPORT_HQ_PROJECT_ID,
themeColor: process.env.NEXT_PUBLIC_SUPPORT_HQ_THEME_COLOR ?? '#2563eb',
...(session?.user && {
metadata: {
userId: session.user.id || '',
name: session.user.name || '',
email: session.user.email || '',
phone: session.user.phone || '',
}
})
})
document.body.appendChild(script)
// @ts-ignore
return () => { window.SupportHQWidget?.destroy() }
}, [session])
return (
<></>
)
}
+2
View File
@@ -8,6 +8,8 @@ import { Routes } from '@/config/routes'
declare module 'next-auth' {
interface Session {
user: {
id?: string
role?: string
phone?: string
avatar?: string
accessToken?: string