diff --git a/api/src/gateway/gateway.controller.ts b/api/src/gateway/gateway.controller.ts index 6bdf1e0..7e6cf75 100644 --- a/api/src/gateway/gateway.controller.ts +++ b/api/src/gateway/gateway.controller.ts @@ -129,4 +129,24 @@ export class GatewayController { const result = await this.gatewayService.getReceivedSMS(deviceId, page, limit) return result; } + + @ApiOperation({ summary: 'Get message history (sent and received) from a device' }) + @ApiResponse({ status: 200, type: RetrieveSMSResponseDTO }) + @ApiQuery({ name: 'page', required: false, type: Number, description: 'Page number (default: 1)' }) + @ApiQuery({ name: 'limit', required: false, type: Number, description: 'Number of items per page (default: 50, max: 100)' }) + @ApiQuery({ name: 'type', required: false, type: String, description: 'Filter by message type: all, sent, or received (default: all)' }) + @UseGuards(AuthGuard, CanModifyDevice) + @Get('/devices/:id/messages') + async getMessages( + @Param('id') deviceId: string, + @Request() req, + ): Promise { + // Extract page and limit from query params, with defaults and max values + const page = req.query.page ? parseInt(req.query.page, 10) : 1; + const limit = req.query.limit ? Math.min(parseInt(req.query.limit, 10), 100) : 50; + const type = req.query.type || ''; + + const result = await this.gatewayService.getMessages(deviceId, type, page, limit); + return result; + } } diff --git a/api/src/gateway/gateway.service.ts b/api/src/gateway/gateway.service.ts index 27bcb7a..31d1f8d 100644 --- a/api/src/gateway/gateway.service.ts +++ b/api/src/gateway/gateway.service.ts @@ -493,6 +493,66 @@ export class GatewayService { }; } + 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) diff --git a/web/app/(app)/dashboard/(components)/message-history.tsx b/web/app/(app)/dashboard/(components)/message-history.tsx new file mode 100644 index 0000000..2566a04 --- /dev/null +++ b/web/app/(app)/dashboard/(components)/message-history.tsx @@ -0,0 +1,752 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { Card, CardContent } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { Clock, Reply, ArrowUpRight, ArrowDownLeft, MessageSquare, Check, X, Smartphone } from 'lucide-react' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { ApiEndpoints } from '@/config/api' +import httpBrowserClient from '@/lib/httpBrowserClient' +import { useForm, Controller } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { sendSmsSchema } from '@/lib/schemas' +import type { SendSmsFormData } from '@/lib/schemas' +import { useMutation } from '@tanstack/react-query' +import { Spinner } from '@/components/ui/spinner' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' +import { Badge } from "@/components/ui/badge" + +// You might need to add this CSS to your global styles or component +const styles = { + deviceSelectorContainer: ` + mb-6 mt-4 p-4 + bg-gradient-to-r from-indigo-50/50 to-purple-50/50 + rounded-xl shadow-sm + border border-indigo-100 + ` +} + +function ReplyDialog({ sms, onClose }: { sms: any; onClose?: () => void }) { + const [open, setOpen] = useState(false) + + const { + mutate: sendSms, + isPending: isSendingSms, + error: sendSmsError, + isSuccess: isSendSmsSuccess, + } = useMutation({ + mutationKey: ['send-sms'], + mutationFn: (data: SendSmsFormData) => + httpBrowserClient.post(ApiEndpoints.gateway.sendSMS(data.deviceId), data), + onSuccess: () => { + setTimeout(() => { + setOpen(false) + if (onClose) onClose() + }, 1500) + }, + }) + + const { + register, + control, + handleSubmit, + formState: { errors }, + reset, + } = useForm({ + resolver: zodResolver(sendSmsSchema), + defaultValues: { + deviceId: sms?.device?._id, + recipients: [sms.sender], + message: '', + }, + }) + + const { data: devices, isLoading: isLoadingDevices } = useQuery({ + queryKey: ['devices'], + queryFn: () => + httpBrowserClient + .get(ApiEndpoints.gateway.listDevices()) + .then((res) => res.data), + }) + + useEffect(() => { + if (open) { + reset({ + deviceId: sms?.device?._id, + recipients: [sms.sender], + message: '', + }) + } + }, [open, sms, reset]) + + return ( + + + + + + + + + Reply to {sms.sender} + + + Send a reply message to this sender + + +
handleSubmit((data) => sendSms(data))(e)} + className='space-y-4 mt-4' + > +
+
+ ( + + )} + /> + {errors.deviceId && ( +

+ {errors.deviceId.message} +

+ )} +
+ +
+ + {errors.recipients?.[0] && ( +

+ {errors.recipients[0].message} +

+ )} +
+ +
+