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 +} diff --git a/web/app/(app)/dashboard/(components)/bulk-sms-send.tsx b/web/app/(app)/dashboard/(components)/bulk-sms-send.tsx new file mode 100644 index 0000000..374f27b --- /dev/null +++ b/web/app/(app)/dashboard/(components)/bulk-sms-send.tsx @@ -0,0 +1,337 @@ +'use client' + +import { useState, useCallback, useMemo } from 'react' +import { useDropzone } from 'react-dropzone' +import Papa from 'papaparse' +import { Upload, Send, AlertCircle, CheckCircle } from 'lucide-react' +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, +} from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { ApiEndpoints } from '@/config/api' +import { useMutation, useQuery } from '@tanstack/react-query' +import { Spinner } from '@/components/ui/spinner' +import httpBrowserClient from '@/lib/httpBrowserClient' + +const MAX_FILE_SIZE = 1024 * 1024 // 1 MB +const MAX_ROWS = 50 + +export default function BulkSMSSend() { + const [csvData, setCsvData] = useState([]) + const [columns, setColumns] = useState([]) + const [selectedColumn, setSelectedColumn] = useState('') + const [messageTemplate, setMessageTemplate] = useState('') + const [selectedRecipient, setSelectedRecipient] = useState('') + const [error, setError] = useState(null) + + const onDrop = useCallback((acceptedFiles: File[]) => { + const file = acceptedFiles[0] + if (file.size > MAX_FILE_SIZE) { + setError('File size exceeds 1 MB limit.') + return + } + + Papa.parse(file, { + complete: (results) => { + if (results.data && results.data.length > 0) { + if (results.data.length > MAX_ROWS) { + setError(`CSV file exceeds ${MAX_ROWS} rows limit.`) + return + } + setCsvData(results.data as any[]) + const headerRow = results.data[0] as Record + setColumns(Object.keys(headerRow)) + setError(null) + } else { + setError('CSV file is empty or invalid') + setCsvData([]) + setColumns([]) + } + }, + header: true, + skipEmptyLines: true, + }) + }, []) + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop }) + + const previewMessage = useMemo(() => { + if (!selectedRecipient || !messageTemplate) return '' + const recipient = csvData.find( + (row) => row[selectedColumn] === selectedRecipient + ) + if (!recipient) return '' + + return messageTemplate.replace(/\{\{\s*([^}]+)\s*\}\}/g, (_, key) => { + return recipient[key.trim()] || '' + }) + }, [selectedRecipient, messageTemplate, csvData, selectedColumn]) + + const handleSendBulkSMS = async () => { + const messages = csvData.map((row) => ({ + message: messageTemplate.replace(/\{\{\s*([^}]+)\s*\}\}/g, (_, key) => { + return row[key.trim()] || '' + }), + recipients: [row[selectedColumn]], + })) + const payload = { + messageTemplate, + messages, + } + await httpBrowserClient.post( + ApiEndpoints.gateway.sendBulkSMS(selectedDeviceId), + payload + ) + } + + const [selectedDeviceId, setSelectedDeviceId] = useState(null) + + const { data: devices } = useQuery({ + queryKey: ['devices'], + queryFn: () => + httpBrowserClient + .get(ApiEndpoints.gateway.listDevices()) + .then((res) => res.data), + }) + + const { + mutate: sendBulkSMS, + isPending: isSendingBulkSMS, + isSuccess: isSendingBulkSMSuccess, + isError: isSendingBulkSMSError, + error: sendingBulkSMSError, + } = useMutation({ + mutationFn: handleSendBulkSMS, + }) + + const isStep2Disabled = csvData.length === 0 + const isStep3Disabled = isStep2Disabled || !selectedColumn || !messageTemplate + + return ( +
+ + + Send Bulk SMS + + Upload a CSV, configure your message, and send bulk SMS in 3 simple + steps. + + + +
+

1. Upload CSV

+

+ Upload a CSV file (max 1MB, {MAX_ROWS} rows) containing recipient + information. +

+
+ + +

+ Drag & drop a CSV file here, or click to select one +

+

+ Max file size: 1MB, Max rows: 50 +

+
+ {error && ( + + + Error + {error} + + )} + {csvData.length > 0 && ( +

+ CSV uploaded successfully! {csvData.length} rows found. +

+ )} +
+ +
+

2. Configure SMS

+

+ Select the recipient column and create your message template. +

+ + {/* select device to send SMS from */} +
+ + +
+ +
+
+ + +
+
+ +