diff --git a/api/src/gateway/gateway.controller.ts b/api/src/gateway/gateway.controller.ts index 7e6cf75..3471244 100644 --- a/api/src/gateway/gateway.controller.ts +++ b/api/src/gateway/gateway.controller.ts @@ -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 }; + } } diff --git a/api/src/gateway/gateway.dto.ts b/api/src/gateway/gateway.dto.ts index dde4e23..f02795d 100644 --- a/api/src/gateway/gateway.dto.ts +++ b/api/src/gateway/gateway.dto.ts @@ -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 +} diff --git a/api/src/gateway/gateway.service.ts b/api/src/gateway/gateway.service.ts index 3866c36..9e1550b 100644 --- a/api/src/gateway/gateway.service.ts +++ b/api/src/gateway/gateway.service.ts @@ -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 { + + 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) diff --git a/api/src/gateway/queue/sms-queue.service.ts b/api/src/gateway/queue/sms-queue.service.ts index a2670af..206f2c3 100644 --- a/api/src/gateway/queue/sms-queue.service.ts +++ b/api/src/gateway/queue/sms-queue.service.ts @@ -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 diff --git a/api/src/gateway/schemas/sms-batch.schema.ts b/api/src/gateway/schemas/sms-batch.schema.ts index 3fee3e6..2629c05 100644 --- a/api/src/gateway/schemas/sms-batch.schema.ts +++ b/api/src/gateway/schemas/sms-batch.schema.ts @@ -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 diff --git a/api/src/gateway/schemas/sms.schema.ts b/api/src/gateway/schemas/sms.schema.ts index 401ba75..1c20add 100644 --- a/api/src/gateway/schemas/sms.schema.ts +++ b/api/src/gateway/schemas/sms.schema.ts @@ -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 }) diff --git a/api/src/webhook/webhook-event.enum.ts b/api/src/webhook/webhook-event.enum.ts index b3b1ecd..8c9421c 100644 --- a/api/src/webhook/webhook-event.enum.ts +++ b/api/src/webhook/webhook-event.enum.ts @@ -1,3 +1,4 @@ export enum WebhookEvent { MESSAGE_RECEIVED = 'MESSAGE_RECEIVED', + SMS_STATUS_UPDATED = 'SMS_STATUS_UPDATED', }