Browse Source

Merge pull request #29 from vernu/bulk-messaging

Bulk messaging
pull/31/head
Israel Abebe 1 year ago
committed by GitHub
parent
commit
979aa9d9d1
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 13
      api/src/gateway/gateway.controller.ts
  2. 16
      api/src/gateway/gateway.dto.ts
  3. 147
      api/src/gateway/gateway.service.ts
  4. 42
      web/app/(app)/(auth)/verify-email.tsx/page.tsx
  5. 337
      web/app/(app)/dashboard/(components)/bulk-sms-send.tsx
  6. 24
      web/app/(app)/dashboard/(components)/messaging.tsx
  7. 1
      web/config/api.ts
  8. 2
      web/package.json
  9. 43
      web/pnpm-lock.yaml

13
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

16
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,

147
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<any> {
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<any> {
const device = await this.deviceModel.findById(deviceId)

42
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 <div>Email is required</div>
}
if (code && email) {
verifyEmail()
}
return <RequestPasswordResetForm />
}

337
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<any[]>([])
const [columns, setColumns] = useState<string[]>([])
const [selectedColumn, setSelectedColumn] = useState<string>('')
const [messageTemplate, setMessageTemplate] = useState<string>('')
const [selectedRecipient, setSelectedRecipient] = useState<string>('')
const [error, setError] = useState<string | null>(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<string, unknown>
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<string | null>(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 (
<div className='container mx-auto p-4 space-y-8'>
<Card>
<CardHeader>
<CardTitle>Send Bulk SMS</CardTitle>
<CardDescription>
Upload a CSV, configure your message, and send bulk SMS in 3 simple
steps.
</CardDescription>
</CardHeader>
<CardContent className='space-y-8'>
<section>
<h2 className='text-lg font-semibold mb-2'>1. Upload CSV</h2>
<p className='text-sm text-gray-500 mb-4'>
Upload a CSV file (max 1MB, {MAX_ROWS} rows) containing recipient
information.
</p>
<div
{...getRootProps()}
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
isDragActive
? 'border-primary bg-primary/10'
: 'border-gray-300'
}`}
>
<input {...getInputProps()} accept='.csv' />
<Upload className='mx-auto h-12 w-12 text-gray-400' />
<p className='mt-2'>
Drag &amp; drop a CSV file here, or click to select one
</p>
<p className='text-sm text-gray-500 mt-1'>
Max file size: 1MB, Max rows: 50
</p>
</div>
{error && (
<Alert variant='destructive' className='mt-4'>
<AlertCircle className='h-4 w-4' />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{csvData.length > 0 && (
<p className='mt-2 text-sm text-green-600'>
CSV uploaded successfully! {csvData.length} rows found.
</p>
)}
</section>
<section
className={isStep2Disabled ? 'opacity-50 pointer-events-none' : ''}
>
<h2 className='text-lg font-semibold mb-2'>2. Configure SMS</h2>
<p className='text-sm text-gray-500 mb-4'>
Select the recipient column and create your message template.
</p>
{/* select device to send SMS from */}
<div>
<Label htmlFor='device-select'>Select Device</Label>
<Select
onValueChange={setSelectedDeviceId}
value={selectedDeviceId}
>
<SelectTrigger id='device-select'>
<SelectValue placeholder='Select a device' />
</SelectTrigger>
<SelectContent>
{devices?.data?.map((device) => (
<SelectItem
key={device._id}
value={device._id}
disabled={!device.enabled}
>
{device.brand} - {device.model}{' '}
{device.enabled ? '' : ' (disabled)'}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='space-y-4'>
<div>
<Label htmlFor='recipient-column'>
Select Recipient Column
</Label>
<Select
onValueChange={setSelectedColumn}
value={selectedColumn}
disabled={isStep2Disabled}
>
<SelectTrigger id='recipient-column'>
<SelectValue placeholder='Select a column' />
</SelectTrigger>
<SelectContent>
{columns.map((column) => (
<SelectItem
key={column}
value={column || 'undefined-column'}
>
{column || 'Unnamed Column'}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor='message-template'>Message Template</Label>
<Textarea
id='message-template'
placeholder='Enter your message template here. Use {{ column_name }} for dynamic content.'
value={messageTemplate}
onChange={(e) => setMessageTemplate(e.target.value)}
className='h-32'
disabled={isStep2Disabled}
/>
</div>
</div>
</section>
<section
className={isStep3Disabled ? 'opacity-50 pointer-events-none' : ''}
>
<h2 className='text-lg font-semibold mb-2'>3. Message Preview</h2>
<p className='text-sm text-gray-500 mb-4'>
Preview your message for a selected recipient before sending.
</p>
<div className='space-y-4'>
<div>
<Label htmlFor='preview-recipient'>
Select Recipient for Preview
</Label>
<Select
onValueChange={setSelectedRecipient}
value={selectedRecipient}
disabled={isStep3Disabled}
>
<SelectTrigger id='preview-recipient'>
<SelectValue placeholder='Select a recipient' />
</SelectTrigger>
<SelectContent>
{csvData
.map((row, index) => {
const value = row[selectedColumn]
if (value) {
return (
<SelectItem key={`${value}-${index}`} value={value}>
{value}
</SelectItem>
)
}
return null
})
.filter(Boolean)}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor='recipient-number'>Recipient Number</Label>
<Input
id='recipient-number'
value={selectedRecipient}
disabled
// className='bg-gray-100'
/>
</div>
<div>
<Label htmlFor='message-preview'>Message Preview</Label>
<Textarea
id='message-preview'
value={previewMessage}
disabled
className='p-4 rounded-md min-h-[100px] whitespace-pre-wrap'
/>
</div>
</div>
</section>
{sendingBulkSMSError && (
<Alert variant='destructive' className='mt-4'>
<AlertCircle className='h-4 w-4' />
<AlertTitle>Error</AlertTitle>
<AlertDescription>
{sendingBulkSMSError?.message}
</AlertDescription>
</Alert>
)}
{isSendingBulkSMSuccess && (
<Alert variant='default' className='mt-4'>
<CheckCircle className='h-4 w-4' />
<AlertTitle>Success</AlertTitle>
<AlertDescription>Bulk SMS sent successfully!</AlertDescription>
</Alert>
)}
<Button
className='w-full'
disabled={isStep3Disabled || isSendingBulkSMS}
onClick={() => sendBulkSMS()}
>
{isSendingBulkSMS ? (
<Spinner size='sm' className='text-white dark:text-black' />
) : (
<Send className='mr-2 h-4 w-4' />
)}
{isSendingBulkSMS ? 'Sending...' : 'Send Bulk SMS'}
</Button>
</CardContent>
</Card>
</div>
)
}

24
web/app/(app)/dashboard/(components)/messaging.tsx

@ -4,8 +4,9 @@ import { useState } from 'react'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import SendSms from './send-sms'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import ReceivedSms from './received-sms'
import BulkSMSSend from './bulk-sms-send'
import { Badge } from '@/components/ui/badge'
export default function Messaging() {
const [currentTab, setCurrentTab] = useState('send')
@ -26,7 +27,13 @@ export default function Messaging() {
Send
</TabsTrigger>
<TabsTrigger value='bulk-send' className='flex-1'>
Bulk Send
Bulk Send{' '}
<Badge
variant='outline'
className='ml-2 bg-blue-500 text-white dark:bg-blue-600 dark:text-white'
>
new
</Badge>
</TabsTrigger>
<TabsTrigger value='received' className='flex-1'>
Received
@ -40,18 +47,7 @@ export default function Messaging() {
<TabsContent value='bulk-send' className='space-y-4'>
{/* comming soon section */}
<div className='grid gap-6 max-w-xl mx-auto mt-10'>
<Card>
<CardHeader>
<CardTitle>Bulk Send</CardTitle>
</CardHeader>
<CardContent>
<div className='flex items-center gap-2'>
<div className='flex items-center gap-2'>
<p>Coming soon...</p>
</div>
</div>
</CardContent>
</Card>
<BulkSMSSend />
</div>
</TabsContent>

1
web/config/api.ts

@ -20,6 +20,7 @@ export const ApiEndpoints = {
gateway: {
listDevices: () => '/gateway/devices',
sendSMS: (id: string) => `/gateway/devices/${id}/send-sms`,
sendBulkSMS: (id: string) => `/gateway/devices/${id}/send-bulk-sms`,
getReceivedSMS: (id: string) => `/gateway/devices/${id}/get-received-sms`,
getStats: () => '/gateway/stats',

2
web/package.json

@ -38,9 +38,11 @@
"next-auth": "^4.24.10",
"next-themes": "^0.4.3",
"nodemailer": "^6.9.16",
"papaparse": "^5.4.1",
"prisma": "^5.22.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.3.5",
"react-hook-form": "^7.53.1",
"react-qr-code": "^2.0.12",
"react-syntax-highlighter": "^15.5.0",

43
web/pnpm-lock.yaml

@ -86,6 +86,9 @@ importers:
nodemailer:
specifier: ^6.9.16
version: 6.9.16
papaparse:
specifier: ^5.4.1
version: 5.4.1
prisma:
specifier: ^5.22.0
version: 5.22.0
@ -95,6 +98,9 @@ importers:
react-dom:
specifier: ^18.2.0
version: 18.2.0(react@18.2.0)
react-dropzone:
specifier: ^14.3.5
version: 14.3.5(react@18.2.0)
react-hook-form:
specifier: ^7.53.1
version: 7.53.1(react@18.2.0)
@ -987,6 +993,10 @@ packages:
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
attr-accept@2.2.5:
resolution: {integrity: sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==}
engines: {node: '>=4'}
autoprefixer@10.4.20:
resolution: {integrity: sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==}
engines: {node: ^10 || ^12 || >=14}
@ -1345,6 +1355,10 @@ packages:
resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==}
engines: {node: ^10.12.0 || >=12.0.0}
file-selector@2.1.2:
resolution: {integrity: sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==}
engines: {node: '>= 12'}
fill-range@7.0.1:
resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==}
engines: {node: '>=8'}
@ -1885,6 +1899,9 @@ packages:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
papaparse@5.4.1:
resolution: {integrity: sha512-HipMsgJkZu8br23pW15uvo6sib6wne/4woLZPlFf3rpDyMe9ywEXUsuD7+6K9PRkJlVT51j/sCOYDKGGS3ZJrw==}
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@ -2030,6 +2047,12 @@ packages:
peerDependencies:
react: ^18.2.0
react-dropzone@14.3.5:
resolution: {integrity: sha512-9nDUaEEpqZLOz5v5SUcFA0CjM4vq8YbqO0WRls+EYT7+DvxUdzDPKNCPLqGfj3YL9MsniCLCD4RFA6M95V6KMQ==}
engines: {node: '>= 10.13'}
peerDependencies:
react: '>= 16.8 || 18.0.0'
react-hook-form@7.53.1:
resolution: {integrity: sha512-6aiQeBda4zjcuaugWvim9WsGqisoUk+etmFEsSUMm451/Ic8L/UAb7sRtMj3V+Hdzm6mMjU1VhiSzYUZeBm0Vg==}
engines: {node: '>=18.0.0'}
@ -2305,6 +2328,9 @@ packages:
tslib@2.6.2:
resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@ -3302,6 +3328,8 @@ snapshots:
asynckit@0.4.0: {}
attr-accept@2.2.5: {}
autoprefixer@10.4.20(postcss@8.4.47):
dependencies:
browserslist: 4.24.2
@ -3788,6 +3816,10 @@ snapshots:
dependencies:
flat-cache: 3.2.0
file-selector@2.1.2:
dependencies:
tslib: 2.8.1
fill-range@7.0.1:
dependencies:
to-regex-range: 5.0.1
@ -4338,6 +4370,8 @@ snapshots:
dependencies:
p-limit: 3.1.0
papaparse@5.4.1: {}
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
@ -4464,6 +4498,13 @@ snapshots:
react: 18.2.0
scheduler: 0.23.0
react-dropzone@14.3.5(react@18.2.0):
dependencies:
attr-accept: 2.2.5
file-selector: 2.1.2
prop-types: 15.8.1
react: 18.2.0
react-hook-form@7.53.1(react@18.2.0):
dependencies:
react: 18.2.0
@ -4774,6 +4815,8 @@ snapshots:
tslib@2.6.2: {}
tslib@2.8.1: {}
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1

Loading…
Cancel
Save