feat(api): track sms status

This commit is contained in:
isra el
2025-06-02 20:47:15 +03:00
parent 64859b38ad
commit 52e88e7e36
7 changed files with 177 additions and 8 deletions

View File

@@ -25,6 +25,7 @@ import {
RetrieveSMSResponseDTO,
SendBulkSMSInputDTO,
SendSMSInputDTO,
UpdateSMSStatusDTO,
} from './gateway.dto'
import { GatewayService } from './gateway.service'
import { CanModifyDevice } from './guards/can-modify-device.guard'
@@ -149,4 +150,15 @@ export class GatewayController {
const result = await this.gatewayService.getMessages(deviceId, type, page, limit);
return result;
}
@ApiOperation({ summary: 'Update SMS status' })
@UseGuards(AuthGuard, CanModifyDevice)
@Patch('/devices/:id/sms-status')
async updateSMSStatus(
@Param('id') deviceId: string,
@Body() dto: UpdateSMSStatusDTO,
) {
const data = await this.gatewayService.updateSMSStatus(deviceId, dto);
return { data };
}
}

View File

@@ -240,3 +240,62 @@ export class RetrieveSMSResponseDTO {
})
meta?: PaginationMetaDTO
}
export class UpdateSMSStatusDTO {
@ApiProperty({
type: String,
required: true,
description: 'The ID of the SMS',
})
smsId: string
@ApiProperty({
type: String,
required: true,
description: 'The ID of the SMS batch',
})
smsBatchId: string
@ApiProperty({
type: String,
required: true,
description: 'The status of the SMS (sent, delivered, failed)',
enum: ['sent', 'delivered', 'failed'],
})
status: string
@ApiProperty({
type: Number,
required: false,
description: 'The time the message was sent (in milliseconds)',
})
sentAtInMillis?: number
@ApiProperty({
type: Number,
required: false,
description: 'The time the message was delivered (in milliseconds)',
})
deliveredAtInMillis?: number
@ApiProperty({
type: Number,
required: false,
description: 'The time the message failed (in milliseconds)',
})
failedAtInMillis?: number
@ApiProperty({
type: String,
required: false,
description: 'Error code if the message failed',
})
errorCode?: string
@ApiProperty({
type: String,
required: false,
description: 'Error message if the message failed',
})
errorMessage?: string
}

View File

@@ -9,6 +9,7 @@ import {
RetrieveSMSDTO,
SendBulkSMSInputDTO,
SendSMSInputDTO,
UpdateSMSStatusDTO,
} from './gateway.dto'
import { User } from '../users/schemas/user.schema'
import { AuthService } from '../auth/auth.service'
@@ -690,6 +691,98 @@ export class GatewayService {
}
}
async updateSMSStatus(deviceId: string, dto: UpdateSMSStatusDTO): Promise<any> {
const device = await this.deviceModel.findById(deviceId);
if (!device) {
throw new HttpException(
{
success: false,
error: 'Device not found',
},
HttpStatus.NOT_FOUND,
);
}
const sms = await this.smsModel.findById(dto.smsId);
if (!sms) {
throw new HttpException(
{
success: false,
error: 'SMS not found',
},
HttpStatus.NOT_FOUND,
);
}
// Verify the SMS belongs to this device
if (sms.device.toString() !== deviceId) {
throw new HttpException(
{
success: false,
error: 'SMS does not belong to this device',
},
HttpStatus.FORBIDDEN,
);
}
// Normalize status to uppercase for comparison
const normalizedStatus = dto.status.toUpperCase();
const updateData: any = {
status: normalizedStatus, // Store normalized status
};
// Update timestamps based on status
if (normalizedStatus === 'SENT' && dto.sentAtInMillis) {
updateData.sentAt = new Date(dto.sentAtInMillis);
} else if (normalizedStatus === 'DELIVERED' && dto.deliveredAtInMillis) {
updateData.deliveredAt = new Date(dto.deliveredAtInMillis);
} else if (normalizedStatus === 'FAILED' && dto.failedAtInMillis) {
updateData.failedAt = new Date(dto.failedAtInMillis);
updateData.errorCode = dto.errorCode;
updateData.errorMessage = dto.errorMessage || 'Unknown error';
}
// Update the SMS
await this.smsModel.findByIdAndUpdate(dto.smsId, { $set: updateData });
// Check if all SMS in batch have the same status, then update batch status
if (dto.smsBatchId) {
const smsBatch = await this.smsBatchModel.findById(dto.smsBatchId);
if (smsBatch) {
const allSmsInBatch = await this.smsModel.find({ smsBatch: dto.smsBatchId });
// Check if all SMS in batch have the same status (case insensitive)
const allHaveSameStatus = allSmsInBatch.every(sms => sms.status.toLowerCase() === normalizedStatus);
if (allHaveSameStatus) {
await this.smsBatchModel.findByIdAndUpdate(dto.smsBatchId, {
$set: { status: normalizedStatus }
});
}
}
}
// Trigger webhook event for SMS status update
try {
this.webhookService.deliverNotification({
sms,
user: device.user,
event: WebhookEvent.SMS_STATUS_UPDATED,
});
} catch (error) {
console.error('Failed to trigger webhook event:', error);
}
return {
success: true,
message: 'SMS status updated successfully',
};
}
async getStatsForUser(user: User) {
const devices = await this.deviceModel.find({ user: user._id })
const apiKeys = await this.authService.getUserApiKeys(user)

View File

@@ -41,6 +41,7 @@ export class SmsQueueService {
batches.push(fcmMessages.slice(i, i + this.maxSmsBatchSize))
}
let delayMultiplier = 1;
for (const batch of batches) {
await this.smsQueue.add(
'send-sms',
@@ -52,7 +53,7 @@ export class SmsQueueService {
{
priority: 1, // TODO: Make this dynamic based on users subscription plan
attempts: 1,
delay: 1000, // 1 second
delay: 1000 * delayMultiplier++,
backoff: {
type: 'exponential',
delay: 5000, // 5 seconds

View File

@@ -32,8 +32,8 @@ export class SMSBatch {
@Prop({ type: Number, default: 0 })
failureCount: number
@Prop({ type: String, default: 'pending', enum: ['pending', 'processing', 'completed', 'partial_success', 'failed'] })
status: string
@Prop({ type: String, default: 'PENDING' })
status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'PARTIAL_SUCCESS' | 'FAILED'
@Prop({ type: String })
error: string

View File

@@ -49,15 +49,18 @@ export class SMS {
@Prop({ type: Date })
failedAt: Date
@Prop({ type: Number, required: false })
errorCode: number
@Prop({ type: String, required: false })
errorMessage: string
// @Prop({ type: String })
// failureReason: string
@Prop({ type: String, default: 'pending', enum: ['pending', 'sent', 'delivered', 'failed'] })
status: string
@Prop({ type: String })
error: string
@Prop({ type: String, default: 'PENDING' })
status: 'PENDING' | 'SENT' | 'DELIVERED' | 'FAILED'
// misc metadata for debugging
@Prop({ type: Object })

View File

@@ -1,3 +1,4 @@
export enum WebhookEvent {
MESSAGE_RECEIVED = 'MESSAGE_RECEIVED',
SMS_STATUS_UPDATED = 'SMS_STATUS_UPDATED',
}