diff --git a/web/app/(app)/dashboard/(components)/bulk-send/bulk-csv.ts b/web/app/(app)/dashboard/(components)/bulk-send/bulk-csv.ts index ae0e4fa..91428c3 100644 --- a/web/app/(app)/dashboard/(components)/bulk-send/bulk-csv.ts +++ b/web/app/(app)/dashboard/(components)/bulk-send/bulk-csv.ts @@ -1,6 +1,8 @@ // Pure CSV / template logic for bulk send. No React and no network, so the // rules that decide who actually receives a message are directly testable. +import { isPlausiblePhone, normalizePhone } from '@/lib/sms' + export type CsvRow = Record export type RecipientRow = { @@ -80,32 +82,6 @@ export function detectRecipientColumn(columns: string[]): string | undefined { return undefined } -/** - * Strip formatting so "+1 (415) 555-0101" and "+14155550101" are recognised as - * the same recipient for duplicate detection. - */ -export function normalizePhone(value: string): string { - const trimmed = value.trim() - const hasPlus = trimmed.startsWith('+') - const digits = trimmed.replace(/\D/g, '') - return hasPlus ? `+${digits}` : digits -} - -/** - * Permissive validity check. The gateway is the real authority on whether a - * number can be reached, so this only rejects input that cannot possibly be a - * phone number, rather than enforcing a strict E.164 shape that would block - * legitimate local formats. - */ -export function isPlausiblePhone(value: string): boolean { - const normalized = normalizePhone(value) - const digits = normalized.replace(/\D/g, '') - if (digits.length < 7 || digits.length > 15) return false - // Reject anything carrying characters that are neither digits, separators, - // nor a leading plus. - return /^\+?[\d\s()./-]+$/.test(value.trim()) -} - /** * Decide who actually receives a message. * @@ -202,3 +178,5 @@ export function formatFileSize(bytes: number): string { const mb = bytes / (1024 * 1024) return `${Number.isInteger(mb) ? mb : mb.toFixed(1)} MB` } + +export { isPlausiblePhone, normalizePhone } diff --git a/web/app/(app)/dashboard/(components)/send-sms.tsx b/web/app/(app)/dashboard/(components)/send-sms.tsx deleted file mode 100644 index 83531e8..0000000 --- a/web/app/(app)/dashboard/(components)/send-sms.tsx +++ /dev/null @@ -1,282 +0,0 @@ -'use client' - -import { useEffect } from 'react' -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@/components/ui/card' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Textarea } from '@/components/ui/textarea' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select' -import { useForm, useFieldArray, Controller, useWatch } from 'react-hook-form' -import { zodResolver } from '@hookform/resolvers/zod' -import { sendSmsSchema } from '@/lib/schemas' -import type { SendSmsFormData } from '@/lib/schemas' -import { MessageSquare, Send, Plus, X, UserCircle, Check } from 'lucide-react' -import { useToast } from '@/hooks/use-toast' -import httpBrowserClient from '@/lib/httpBrowserClient' -import { ApiEndpoints } from '@/config/api' -import { useMutation, useQuery } from '@tanstack/react-query' -import { Spinner } from '@/components/ui/spinner' -import { formatError } from '@/lib/utils/errorHandler' -import { RateLimitError } from '@/components/shared/rate-limit-error' -import { formatDeviceName } from '@/lib/utils' - -export default function SendSms() { - const { data: devices, isLoading: isLoadingDevices } = useQuery({ - queryKey: ['devices'], - queryFn: () => - httpBrowserClient - .get(ApiEndpoints.gateway.listDevices()) - .then((res) => res.data), - }) - - const { - mutate: sendSms, - isPending: isSendingSms, - error: sendSmsError, - isSuccess: isSendSmsSuccess, - } = useMutation({ - mutationKey: ['send-sms'], - mutationFn: (data: SendSmsFormData) => - httpBrowserClient.post(ApiEndpoints.gateway.sendSMS(data.deviceId), data), - }) - - const { toast } = useToast() - const { - register, - control, - handleSubmit, - setValue, - formState: { errors }, - } = useForm({ - resolver: zodResolver(sendSmsSchema), - defaultValues: { - deviceId: - devices?.data?.length === 1 ? devices?.data?.[0]?._id : undefined, - recipients: [''], - message: '', - }, - }) - - const { fields, append, remove } = useFieldArray({ - control, - // @ts-expect-error - name: 'recipients', - }) - - const selectedDeviceId = useWatch({ - control, - name: 'deviceId', - }) - - const selectedDevice = devices?.data?.find( - (device) => device._id === selectedDeviceId - ) - - const availableSims = - selectedDevice?.simInfo?.sims && - Array.isArray(selectedDevice.simInfo.sims) && - selectedDevice.simInfo.sims.length > 0 - ? selectedDevice.simInfo.sims - : [] - - useEffect(() => { - if (selectedDeviceId) { - setValue('simSubscriptionId', undefined) - } - }, [selectedDeviceId, setValue]) - - return ( -
- - -
- - Send SMS -
- Send a message to any recipient(s) -
- -
handleSubmit((data) => sendSms(data))(e)} - className='space-y-4' - > -
-
- ( - - )} - /> - {errors.deviceId && ( -

- {errors.deviceId.message} -

- )} -
- - {availableSims.length > 1 && ( -
- ( - - )} - /> - {errors.simSubscriptionId && ( -

- {errors.simSubscriptionId.message} -

- )} -
- )} - -
- {fields.map((field, index) => ( -
-
- - -
- {errors.recipients?.[index] && ( -

- {errors.recipients[index].message} -

- )} -
- ))} - - - {errors.recipients && ( -

- {errors.recipients.message} -

- )} - - {errors.recipients?.root && ( -

- {errors.recipients.root.message} -

- )} -
- -
-