diff --git a/api/src/gateway/gateway.controller.ts b/api/src/gateway/gateway.controller.ts index 5a6f43e..2d104ad 100644 --- a/api/src/gateway/gateway.controller.ts +++ b/api/src/gateway/gateway.controller.ts @@ -23,6 +23,7 @@ import { ReceivedSMSDTO, RegisterDeviceInputDTO, RetrieveSMSResponseDTO, + SendBulkSMSInputDTO, SendSMSInputDTO, } from './gateway.dto' import { GatewayService } from './gateway.service' @@ -88,6 +89,18 @@ export class GatewayController { return { data } } + @ApiOperation({ summary: 'Send Bulk SMS' }) + @UseGuards(AuthGuard, CanModifyDevice) + @Post(['/devices/:id/send-bulk-sms']) + async sendBulkSMS( + @Param('id') deviceId: string, + @Body() body: SendBulkSMSInputDTO, + ) { + const data = await this.gatewayService.sendBulkSMS(deviceId, body) + return { data } + } + + @ApiOperation({ summary: 'Received SMS from a device' }) @HttpCode(HttpStatus.OK) // deprecate receiveSMS route in favor of receive-sms diff --git a/api/src/gateway/gateway.dto.ts b/api/src/gateway/gateway.dto.ts index b5721ad..6096ae7 100644 --- a/api/src/gateway/gateway.dto.ts +++ b/api/src/gateway/gateway.dto.ts @@ -78,6 +78,22 @@ export class SMSData { } export class SendSMSInputDTO extends SMSData {} +export class SendBulkSMSInputDTO { + @ApiProperty({ + type: String, + required: true, + description: 'The template to send the SMS with', + }) + messageTemplate: string + + @ApiProperty({ + type: [SMSData], + required: true, + description: 'The messages to send', + }) + messages: SMSData[] +} + export class ReceivedSMSDTO { @ApiProperty({ type: String, diff --git a/api/src/gateway/gateway.service.ts b/api/src/gateway/gateway.service.ts index efc6c30..3db06c5 100644 --- a/api/src/gateway/gateway.service.ts +++ b/api/src/gateway/gateway.service.ts @@ -7,6 +7,7 @@ import { ReceivedSMSDTO, RegisterDeviceInputDTO, RetrieveSMSDTO, + SendBulkSMSInputDTO, SendSMSInputDTO, } from './gateway.dto' import { User } from '../users/schemas/user.schema' @@ -14,7 +15,10 @@ 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 { Message } from 'firebase-admin/lib/messaging/messaging-api' +import { + BatchResponse, + Message, +} from 'firebase-admin/lib/messaging/messaging-api' @Injectable() export class GatewayService { constructor( @@ -35,7 +39,10 @@ export class GatewayService { }) if (device) { - return await this.updateDevice(device._id.toString(), { ...input, enabled: true }) + return await this.updateDevice(device._id.toString(), { + ...input, + enabled: true, + }) } else { return await this.deviceModel.create({ ...input, user }) } @@ -218,6 +225,142 @@ export class GatewayService { } } + 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, + ) + } + + if (body.messages.map((m) => m.recipients).flat().length > 50) { + throw new HttpException( + { + success: false, + error: 'Maximum of 50 recipients per batch is allowed', + }, + HttpStatus.BAD_REQUEST, + ) + } + + 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(), + ), + }) + + const fcmResponses: BatchResponse[] = [] + for (const smsData of messages) { + const message = smsData.message + const recipients = smsData.recipients + + if (!message) { + continue + } + + if (!Array.isArray(recipients) || recipients.length === 0) { + continue + } + + 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(), + }) + 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) + } + + try { + const response = await firebaseAdmin.messaging().sendEach(fcmMessages) + + 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) + }) + } catch (e) { + console.log('Failed to send SMS: FCM') + console.log(e) + } + } + + 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) diff --git a/web/app/(app)/(auth)/verify-email.tsx/page.tsx b/web/app/(app)/(auth)/verify-email.tsx/page.tsx new file mode 100644 index 0000000..fd58f62 --- /dev/null +++ b/web/app/(app)/(auth)/verify-email.tsx/page.tsx @@ -0,0 +1,42 @@ +'use client' + +import { useSearchParams } from 'next/navigation' +import ResetPasswordForm from '../(components)/reset-password-form' + +import RequestPasswordResetForm from '../(components)/request-password-reset-form' +import { useQuery } from '@tanstack/react-query' +import httpBrowserClient from '@/lib/httpBrowserClient' +import { ApiEndpoints } from '@/config/api' +import { useMutation } from '@tanstack/react-query' +import { useSession } from 'next-auth/react' + +export default function ResetPasswordPage() { + const searchParams = useSearchParams() + const code = searchParams.get('code') + const email = searchParams.get('email') + + const session = useSession() + const { + mutate: verifyEmail, + isPending: isVerifyingEmail, + isSuccess: isVerifyingEmailSuccess, + isError: isVerifyingEmailError, + error: verifyingEmailError, + } = useMutation({ + mutationFn: () => + httpBrowserClient.post(ApiEndpoints.auth.verifyEmail(), { + email: decodeURIComponent(email), + code, + }), + }) + + if (!email) { + return
Email is required
+ } + + if (code && email) { + verifyEmail() + } + + return +}