mirror of
https://github.com/vernu/textbee.git
synced 2026-09-03 03:29:58 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d613217efb | ||
|
|
598d697306 | ||
|
|
5ae010f1c3 | ||
|
|
94a0cd8c0a | ||
|
|
2ec9207779 | ||
|
|
51cb1ca198 | ||
|
|
e49f3954c3 | ||
|
|
17351e2148 | ||
|
|
8eec17ba1b | ||
|
|
25c70eb09d | ||
|
|
acde97f080 | ||
|
|
e14b71a790 | ||
|
|
5023f80f0f | ||
|
|
2bdcdeb9a9 | ||
|
|
b42a681816 | ||
|
|
14fd2f9b03 | ||
|
|
540bb9068b | ||
|
|
3061bd8d66 | ||
|
|
338fb665bc | ||
|
|
71475f0db8 | ||
|
|
40b0df297c | ||
|
|
863aac04e8 | ||
|
|
6e7ed42fe8 | ||
|
|
8fd06f06d1 | ||
|
|
0144cff16e | ||
|
|
7ff6ea23fc | ||
|
|
fd4969c92c | ||
|
|
4d1b53e247 | ||
|
|
c668530188 | ||
|
|
3cd1d94b20 | ||
|
|
30591c4847 | ||
|
|
f5bef39cec | ||
|
|
482049a764 | ||
|
|
ac65907453 | ||
|
|
584e2c5bec | ||
|
|
9685ecb7b3 | ||
|
|
d8460bcb8b | ||
|
|
eb94085f6e |
@@ -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"
|
||||
|
||||
@@ -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 0–1 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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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>© {{currentYear}} textBee.dev.</p>
|
||||
<p>© {{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>© {{currentYear}} textBee.dev.</p>
|
||||
<p>© {{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 =
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
|
||||
@@ -53,7 +53,7 @@ export default function DashboardLayout({
|
||||
<div className='space-y-2 p-4'>
|
||||
<VerifyEmailAlert />
|
||||
<AccountDeletionAlert />
|
||||
{/* <UpgradeToProAlert /> */}
|
||||
<UpgradeToProAlert />
|
||||
{/* <BlackFridayModal /> */}
|
||||
</div>
|
||||
{children}
|
||||
|
||||
@@ -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 (
|
||||
<></>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user