mirror of
https://github.com/vernu/textbee.git
synced 2026-05-19 14:02:04 -04:00
fix(api): fix duplicate billing notification emails
This commit is contained in:
@@ -28,23 +28,35 @@ export class BillingNotificationsService {
|
||||
) {}
|
||||
|
||||
async notifyOnce({ userId, type, title, message, meta = {}, sendEmail = true }: NotifyOnceInput) {
|
||||
const recent = await this.findRecentSimilar(userId, type)
|
||||
if (recent) {
|
||||
return recent
|
||||
const windowMs = this.getDedupeWindowMs(type)
|
||||
const existing = await this.notificationModel.findOne({
|
||||
user: new Types.ObjectId(userId),
|
||||
type,
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
const lastSentAt = existing.lastEmailSentAt || existing.createdAt
|
||||
if (lastSentAt && lastSentAt.getTime() >= Date.now() - windowMs) {
|
||||
return existing
|
||||
}
|
||||
}
|
||||
|
||||
const created = await this.createNotification(userId, type, title, message, meta)
|
||||
const updated = await this.notificationModel.findOneAndUpdate(
|
||||
{ user: new Types.ObjectId(userId), type },
|
||||
{ $set: { title, message, meta }, $setOnInsert: { user: new Types.ObjectId(userId), type } },
|
||||
{ upsert: true, new: true, setDefaultsOnInsert: true },
|
||||
)
|
||||
|
||||
await this.billingQueue.add(
|
||||
'send',
|
||||
{
|
||||
notificationId: created._id,
|
||||
userId: created.user,
|
||||
type: created.type,
|
||||
title: created.title,
|
||||
message: created.message,
|
||||
meta: created.meta,
|
||||
createdAt: created.createdAt,
|
||||
notificationId: updated._id,
|
||||
userId: updated.user,
|
||||
type: updated.type,
|
||||
title: updated.title,
|
||||
message: updated.message,
|
||||
meta: updated.meta,
|
||||
createdAt: updated.createdAt,
|
||||
sendEmail,
|
||||
},
|
||||
{
|
||||
@@ -52,10 +64,11 @@ export class BillingNotificationsService {
|
||||
attempts: 3,
|
||||
removeOnComplete: true,
|
||||
backoff: { type: 'exponential', delay: 2000 },
|
||||
jobId: updated._id.toString(),
|
||||
},
|
||||
)
|
||||
|
||||
return created
|
||||
return updated
|
||||
}
|
||||
|
||||
async listForUser(userId: Types.ObjectId | string, { limit = 50 } = {}) {
|
||||
@@ -78,30 +91,7 @@ export class BillingNotificationsService {
|
||||
return hours * 60 * 60 * 1000
|
||||
}
|
||||
|
||||
private async findRecentSimilar(userId: Types.ObjectId | string, type: BillingNotificationType) {
|
||||
const since = new Date(Date.now() - this.getDedupeWindowMs(type))
|
||||
return this.notificationModel.findOne({
|
||||
user: new Types.ObjectId(userId),
|
||||
type,
|
||||
createdAt: { $gte: since },
|
||||
})
|
||||
}
|
||||
|
||||
private async createNotification(
|
||||
userId: Types.ObjectId | string,
|
||||
type: BillingNotificationType,
|
||||
title: string,
|
||||
message: string,
|
||||
meta: Record<string, any>,
|
||||
) {
|
||||
return this.notificationModel.create({
|
||||
user: new Types.ObjectId(userId),
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
meta,
|
||||
})
|
||||
}
|
||||
// upsert-based single-document per user+type; dedupe controlled by window
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -605,39 +605,31 @@ export class BillingService {
|
||||
}
|
||||
|
||||
//if plan is pro and monthly limit is exceeded, give them 30% more monthly limit
|
||||
if (plan.name?.startsWith('pro') && monthlyExceeded && !dailyExceeded && !bulkExceeded) {
|
||||
if (
|
||||
plan.name?.startsWith('pro') &&
|
||||
monthlyExceeded &&
|
||||
!dailyExceeded &&
|
||||
!bulkExceeded
|
||||
) {
|
||||
const extendedMonthlyLimit = Math.floor(plan.monthlyLimit * 1.3)
|
||||
const exceedsExtended =
|
||||
processedSmsLastMonth + value > extendedMonthlyLimit
|
||||
if (!exceedsExtended) {
|
||||
return true
|
||||
}
|
||||
throw new HttpException(
|
||||
{
|
||||
message: message,
|
||||
hasReachedLimit: true,
|
||||
dailyLimit: plan.dailyLimit,
|
||||
dailyRemaining: plan.dailyLimit - processedSmsToday,
|
||||
monthlyRemaining: plan.monthlyLimit - processedSmsLastMonth,
|
||||
bulkSendLimit: plan.bulkSendLimit,
|
||||
monthlyLimit: plan.monthlyLimit,
|
||||
},
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
)
|
||||
} else {
|
||||
throw new HttpException(
|
||||
{
|
||||
message: message,
|
||||
hasReachedLimit: true,
|
||||
dailyLimit: plan.dailyLimit,
|
||||
dailyRemaining: plan.dailyLimit - processedSmsToday,
|
||||
monthlyRemaining: plan.monthlyLimit - processedSmsLastMonth,
|
||||
bulkSendLimit: plan.bulkSendLimit,
|
||||
monthlyLimit: plan.monthlyLimit,
|
||||
},
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
)
|
||||
}
|
||||
throw new HttpException(
|
||||
{
|
||||
message: message,
|
||||
hasReachedLimit: true,
|
||||
dailyLimit: plan.dailyLimit,
|
||||
dailyRemaining: plan.dailyLimit - processedSmsToday,
|
||||
monthlyRemaining: plan.monthlyLimit - processedSmsLastMonth,
|
||||
bulkSendLimit: plan.bulkSendLimit,
|
||||
monthlyLimit: plan.monthlyLimit,
|
||||
},
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
)
|
||||
}
|
||||
|
||||
return true
|
||||
|
||||
@@ -40,6 +40,15 @@ export class BillingNotificationsProcessor {
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure we do not resend within the dedupe window
|
||||
const notif = await this.notificationModel.findById(payload.notificationId)
|
||||
if (!notif) return
|
||||
const windowMs = this.getDedupeWindowMs(payload.type as any)
|
||||
const lastSentAt = notif.lastEmailSentAt || notif.createdAt
|
||||
if (lastSentAt && lastSentAt.getTime() >= Date.now() - windowMs) {
|
||||
return
|
||||
}
|
||||
|
||||
const subject = this.subjectForType(payload.type, payload.title)
|
||||
const ctaUrlBase = process.env.FRONTEND_URL || 'https://app.textbee.dev'
|
||||
const isEmailVerification = payload.type === 'email_verification_required'
|
||||
@@ -69,6 +78,19 @@ export class BillingNotificationsProcessor {
|
||||
)
|
||||
}
|
||||
|
||||
private getDedupeWindowMs(type: string) {
|
||||
const map: Record<string, number> = {
|
||||
email_verification_required: 24,
|
||||
daily_limit_reached: 12,
|
||||
monthly_limit_reached: 48,
|
||||
bulk_sms_limit_reached: 12,
|
||||
daily_limit_approaching: 24,
|
||||
monthly_limit_approaching: 48,
|
||||
}
|
||||
const hours = map[type] ?? 24
|
||||
return hours * 60 * 60 * 1000
|
||||
}
|
||||
|
||||
private subjectForType(type: string, fallback: string) {
|
||||
switch (type) {
|
||||
case 'daily_limit_reached':
|
||||
|
||||
Reference in New Issue
Block a user