import { HttpException, HttpStatus, Injectable } from '@nestjs/common' import { InjectModel } from '@nestjs/mongoose' import { Device, DeviceDocument } from './schemas/device.schema' import { Model } from 'mongoose' import * as firebaseAdmin from 'firebase-admin' import { ReceivedSMSDTO, RegisterDeviceInputDTO, RetrieveSMSDTO, SendBulkSMSInputDTO, SendSMSInputDTO, } from './gateway.dto' import { User } from '../users/schemas/user.schema' import { AuthService } from 'src/auth/auth.service' import { SMS } from './schemas/sms.schema' import { SMSType } from './sms-type.enum' import { SMSBatch } from './schemas/sms-batch.schema' import { BatchResponse, Message } from 'firebase-admin/messaging' import { WebhookEvent } from 'src/webhook/webhook-event.enum' import { WebhookService } from 'src/webhook/webhook.service' import { BillingService } from 'src/billing/billing.service' import { SmsQueueService } from './queue/sms-queue.service' @Injectable() export class GatewayService { constructor( @InjectModel(Device.name) private deviceModel: Model, @InjectModel(SMS.name) private smsModel: Model, @InjectModel(SMSBatch.name) private smsBatchModel: Model, private authService: AuthService, private webhookService: WebhookService, private billingService: BillingService, private smsQueueService: SmsQueueService, ) {} async registerDevice( input: RegisterDeviceInputDTO, user: User, ): Promise { const device = await this.deviceModel.findOne({ user: user._id, model: input.model, buildId: input.buildId, }) if (device) { return await this.updateDevice(device._id.toString(), { ...input, enabled: true, }) } else { return await this.deviceModel.create({ ...input, user }) } } async getDevicesForUser(user: User): Promise { return await this.deviceModel.find({ user: user._id }) } async getDeviceById(deviceId: string): Promise { return await this.deviceModel.findById(deviceId) } async updateDevice( deviceId: string, input: RegisterDeviceInputDTO, ): Promise { const device = await this.deviceModel.findById(deviceId) if (!device) { throw new HttpException( { error: 'Device not found', }, HttpStatus.NOT_FOUND, ) } return await this.deviceModel.findByIdAndUpdate( deviceId, { $set: input }, { new: true }, ) } async deleteDevice(deviceId: string): Promise { const device = await this.deviceModel.findById(deviceId) if (!device) { throw new HttpException( { error: 'Device not found', }, HttpStatus.NOT_FOUND, ) } return {} // return await this.deviceModel.findByIdAndDelete(deviceId) } async sendSMS(deviceId: string, smsData: SendSMSInputDTO): Promise { const device = await this.deviceModel.findById(deviceId) if (!device?.enabled) { throw new HttpException( { success: false, error: 'Device does not exist or is not enabled', }, HttpStatus.BAD_REQUEST, ) } const message = smsData.message || smsData.smsBody const recipients = smsData.recipients || smsData.receivers if (!message) { throw new HttpException( { success: false, error: 'Message cannot be blank', }, HttpStatus.BAD_REQUEST, ) } if (!Array.isArray(recipients) || recipients.length === 0) { throw new HttpException( { success: false, error: 'Invalid recipients', }, HttpStatus.BAD_REQUEST, ) } await this.billingService.canPerformAction( device.user.toString(), 'send_sms', recipients.length, ) // TODO: Implement a queue to send the SMS if recipients are too many let smsBatch: SMSBatch try { smsBatch = await this.smsBatchModel.create({ device: device._id, message, recipientCount: recipients.length, recipientPreview: this.getRecipientsPreview(recipients), status: 'pending', }) } catch (e) { throw new HttpException( { success: false, error: 'Failed to create SMS batch', additionalInfo: e, }, HttpStatus.BAD_REQUEST, ) } const fcmMessages: Message[] = [] for (const recipient of recipients) { const sms = await this.smsModel.create({ device: device._id, smsBatch: smsBatch._id, message: message, type: SMSType.SENT, recipient, requestedAt: new Date(), status: 'pending', }) const updatedSMSData = { smsId: sms._id, smsBatchId: smsBatch._id, message, recipients: [recipient], // Legacy fields to be removed in the future smsBody: message, receivers: [recipient], } const stringifiedSMSData = JSON.stringify(updatedSMSData) const fcmMessage: Message = { data: { smsData: stringifiedSMSData, }, token: device.fcmToken, android: { priority: 'high', }, } fcmMessages.push(fcmMessage) } // Check if we should use the queue if (this.smsQueueService.isQueueEnabled()) { try { // Update batch status to processing await this.smsBatchModel.findByIdAndUpdate(smsBatch._id, { $set: { status: 'processing' }, }) // Add to queue await this.smsQueueService.addSendSmsJob( deviceId, fcmMessages, smsBatch._id.toString(), ) return { success: true, message: 'SMS added to queue for processing', smsBatchId: smsBatch._id, recipientCount: recipients.length, } } catch (e) { // Update batch status to failed await this.smsBatchModel.findByIdAndUpdate(smsBatch._id, { $set: { status: 'failed', error: e.message }, }) // Update all SMS in batch to failed await this.smsModel.updateMany( { smsBatch: smsBatch._id }, { $set: { status: 'failed', error: e.message } }, ) throw new HttpException( { success: false, error: 'Failed to add SMS to queue', additionalInfo: e, }, HttpStatus.INTERNAL_SERVER_ERROR, ) } } try { const response = await firebaseAdmin.messaging().sendEach(fcmMessages) console.log(response) if (response.successCount === 0) { throw new HttpException( { success: false, error: 'Failed to send SMS', additionalInfo: response, }, HttpStatus.BAD_REQUEST, ) } this.deviceModel .findByIdAndUpdate(deviceId, { $inc: { sentSMSCount: response.successCount }, }) .exec() .catch((e) => { console.log('Failed to update sentSMSCount') console.log(e) }) this.smsBatchModel .findByIdAndUpdate(smsBatch._id, { $set: { status: 'completed' }, }) .exec() .catch((e) => { console.error('failed to update sms batch status to completed') }) return response } catch (e) { this.smsBatchModel .findByIdAndUpdate(smsBatch._id, { $set: { status: 'failed', error: e.message }, }) .exec() .catch((e) => { console.error('failed to update sms batch status to failed') }) throw new HttpException( { success: false, error: 'Failed to send SMS', additionalInfo: e, }, HttpStatus.BAD_REQUEST, ) } } async sendBulkSMS(deviceId: string, body: SendBulkSMSInputDTO): Promise { const device = await this.deviceModel.findById(deviceId) if (!device?.enabled) { throw new HttpException( { success: false, error: 'Device does not exist or is not enabled', }, HttpStatus.BAD_REQUEST, ) } if ( !Array.isArray(body.messages) || body.messages.length === 0 || body.messages.map((m) => m.recipients).flat().length === 0 ) { throw new HttpException( { success: false, error: 'Invalid message list', }, HttpStatus.BAD_REQUEST, ) } await this.billingService.canPerformAction( device.user.toString(), 'bulk_send_sms', body.messages.map((m) => m.recipients).flat().length, ) const { messageTemplate, messages } = body const smsBatch = await this.smsBatchModel.create({ device: device._id, message: messageTemplate, recipientCount: messages .map((m) => m.recipients.length) .reduce((a, b) => a + b, 0), recipientPreview: this.getRecipientsPreview( messages.map((m) => m.recipients).flat(), ), status: 'pending', }) const fcmMessages: Message[] = [] for (const smsData of messages) { const message = smsData.message const recipients = smsData.recipients if (!message) { continue } if (!Array.isArray(recipients) || recipients.length === 0) { continue } for (const recipient of recipients) { const sms = await this.smsModel.create({ device: device._id, smsBatch: smsBatch._id, message: message, type: SMSType.SENT, recipient, requestedAt: new Date(), status: 'pending', }) const updatedSMSData = { smsId: sms._id, smsBatchId: smsBatch._id, message, recipients: [recipient], // Legacy fields to be removed in the future smsBody: message, receivers: [recipient], } const stringifiedSMSData = JSON.stringify(updatedSMSData) const fcmMessage: Message = { data: { smsData: stringifiedSMSData, }, token: device.fcmToken, android: { priority: 'high', }, } fcmMessages.push(fcmMessage) } } // Check if we should use the queue if (this.smsQueueService.isQueueEnabled()) { try { // Add to queue await this.smsQueueService.addSendSmsJob( deviceId, fcmMessages, smsBatch._id.toString(), ) return { success: true, message: 'Bulk SMS added to queue for processing', smsBatchId: smsBatch._id, recipientCount: messages.map((m) => m.recipients).flat().length, } } catch (e) { // Update batch status to failed await this.smsBatchModel.findByIdAndUpdate(smsBatch._id, { $set: { status: 'failed', error: e.message, successCount: 0, failureCount: fcmMessages.length, }, }) // Update all SMS in batch to failed await this.smsModel.updateMany( { smsBatch: smsBatch._id }, { $set: { status: 'failed', error: e.message } }, ) throw new HttpException( { success: false, error: 'Failed to add bulk SMS to queue', additionalInfo: e, }, HttpStatus.INTERNAL_SERVER_ERROR, ) } } const fcmMessagesBatches = fcmMessages.map((m) => [m]) const fcmResponses: BatchResponse[] = [] for (const batch of fcmMessagesBatches) { try { const response = await firebaseAdmin.messaging().sendEach(batch) console.log(response) fcmResponses.push(response) this.deviceModel .findByIdAndUpdate(deviceId, { $inc: { sentSMSCount: response.successCount }, }) .exec() .catch((e) => { console.log('Failed to update sentSMSCount') console.log(e) }) this.smsBatchModel .findByIdAndUpdate(smsBatch._id, { $set: { status: 'completed' }, }) .exec() .catch((e) => { console.error('failed to update sms batch status to completed') }) } catch (e) { console.log('Failed to send SMS: FCM') console.log(e) this.smsBatchModel .findByIdAndUpdate(smsBatch._id, { $set: { status: 'failed', error: e.message }, }) .exec() .catch((e) => { console.error('failed to update sms batch status to failed') }) } } const successCount = fcmResponses.reduce( (acc, m) => acc + m.successCount, 0, ) const failureCount = fcmResponses.reduce( (acc, m) => acc + m.failureCount, 0, ) const response = { success: successCount > 0, successCount, failureCount, fcmResponses, } return response } async receiveSMS(deviceId: string, dto: ReceivedSMSDTO): Promise { const device = await this.deviceModel.findById(deviceId) if (!device) { throw new HttpException( { success: false, error: 'Device does not exist', }, HttpStatus.BAD_REQUEST, ) } if ( (!dto.receivedAt && !dto.receivedAtInMillis) || !dto.sender || !dto.message ) { console.log('Invalid received SMS data') throw new HttpException( { success: false, error: 'Invalid received SMS data', }, HttpStatus.BAD_REQUEST, ) } await this.billingService.canPerformAction( device.user.toString(), 'receive_sms', 1, ) const receivedAt = dto.receivedAtInMillis ? new Date(dto.receivedAtInMillis) : dto.receivedAt const sms = await this.smsModel.create({ device: device._id, message: dto.message, type: SMSType.RECEIVED, sender: dto.sender, receivedAt, }) this.deviceModel .findByIdAndUpdate(deviceId, { $inc: { receivedSMSCount: 1 }, }) .exec() .catch((e) => { console.log('Failed to update receivedSMSCount') console.log(e) }) this.webhookService .deliverNotification({ sms, user: device.user, event: WebhookEvent.MESSAGE_RECEIVED, }) .catch((e) => { console.log(e) }) return sms } async getReceivedSMS( deviceId: string, page = 1, limit = 50, ): Promise<{ data: any[]; meta: any }> { const device = await this.deviceModel.findById(deviceId) if (!device) { throw new HttpException( { success: false, error: 'Device does not exist', }, HttpStatus.BAD_REQUEST, ) } // Calculate skip value for pagination const skip = (page - 1) * limit // Get total count for pagination metadata const total = await this.smsModel.countDocuments({ device: device._id, type: SMSType.RECEIVED, }) // @ts-ignore const data = await this.smsModel .find( { device: device._id, type: SMSType.RECEIVED, }, null, { sort: { receivedAt: -1 }, limit: limit, skip: skip, }, ) .populate({ path: 'device', select: '_id brand model buildId enabled', }) .lean() // Use lean() to return plain JavaScript objects instead of Mongoose documents // Calculate pagination metadata const totalPages = Math.ceil(total / limit) return { meta: { page, limit, total, totalPages, }, data, } } async getMessages( deviceId: string, type = '', page = 1, limit = 50, ): Promise<{ data: any[]; meta: any }> { const device = await this.deviceModel.findById(deviceId) if (!device) { throw new HttpException( { success: false, error: 'Device does not exist', }, HttpStatus.BAD_REQUEST, ) } // Calculate skip value for pagination const skip = (page - 1) * limit // Build query based on type filter const query: any = { device: device._id } if (type === 'sent') { query.type = SMSType.SENT } else if (type === 'received') { query.type = SMSType.RECEIVED } // Get total count for pagination metadata const total = await this.smsModel.countDocuments(query) // @ts-ignore const data = await this.smsModel .find(query, null, { // Sort by the most recent timestamp (receivedAt for received, sentAt for sent) sort: { createdAt: -1 }, limit: limit, skip: skip, }) .populate({ path: 'device', select: '_id brand model buildId enabled', }) .lean() // Use lean() to return plain JavaScript objects instead of Mongoose documents // Calculate pagination metadata const totalPages = Math.ceil(total / limit) return { meta: { page, limit, total, totalPages, }, data, } } async getStatsForUser(user: User) { const devices = await this.deviceModel.find({ user: user._id }) const apiKeys = await this.authService.getUserApiKeys(user) const totalSentSMSCount = devices.reduce((acc, device) => { return acc + (device.sentSMSCount || 0) }, 0) const totalReceivedSMSCount = devices.reduce((acc, device) => { return acc + (device.receivedSMSCount || 0) }, 0) const totalDeviceCount = devices.length const totalApiKeyCount = apiKeys.length return { totalSentSMSCount, totalReceivedSMSCount, totalDeviceCount, totalApiKeyCount, } } private getRecipientsPreview(recipients: string[]): string { if (recipients.length === 0) { return null } else if (recipients.length === 1) { return recipients[0] } else if (recipients.length === 2) { return `${recipients[0]} and ${recipients[1]}` } else if (recipients.length === 3) { return `${recipients[0]}, ${recipients[1]}, and ${recipients[2]}` } else { return `${recipients[0]}, ${recipients[1]}, and ${ recipients.length - 2 } others` } } }